@rc-component/trigger
Version:
base abstract trigger component for react
464 lines (428 loc) • 15.4 kB
JavaScript
import Portal from '@rc-component/portal';
import classNames from 'classnames';
import ResizeObserver from '@rc-component/resize-observer';
import { isDOM } from "@rc-component/util/es/Dom/findDOMNode";
import { getShadowRoot } from "@rc-component/util/es/Dom/shadow";
import useEvent from "@rc-component/util/es/hooks/useEvent";
import useId from "@rc-component/util/es/hooks/useId";
import useLayoutEffect from "@rc-component/util/es/hooks/useLayoutEffect";
import isMobile from "@rc-component/util/es/isMobile";
import * as React from 'react';
import Popup from "./Popup";
import TriggerWrapper from "./TriggerWrapper";
import TriggerContext from "./context";
import useAction from "./hooks/useAction";
import useAlign from "./hooks/useAlign";
import useWatch from "./hooks/useWatch";
import useWinClick from "./hooks/useWinClick";
import { getAlignPopupClassName } from "./util";
// Removed Props List
// Seems this can be auto
// getDocument?: (element?: HTMLElement) => Document;
// New version will not wrap popup with `rc-trigger-popup-content` when multiple children
export function generateTrigger(PortalComponent = Portal) {
const Trigger = /*#__PURE__*/React.forwardRef((props, ref) => {
const {
prefixCls = 'rc-trigger-popup',
children,
// Action
action = 'hover',
showAction,
hideAction,
// Open
popupVisible,
defaultPopupVisible,
onOpenChange,
afterOpenChange,
onPopupVisibleChange,
afterPopupVisibleChange,
// Delay
mouseEnterDelay,
mouseLeaveDelay = 0.1,
focusDelay,
blurDelay,
// Mask
mask,
maskClosable = true,
// Portal
getPopupContainer,
forceRender,
autoDestroy,
// Popup
popup,
popupClassName,
popupStyle,
popupPlacement,
builtinPlacements = {},
popupAlign,
zIndex,
stretch,
getPopupClassNameFromAlign,
fresh,
alignPoint,
onPopupClick,
onPopupAlign,
// Arrow
arrow,
// Motion
popupMotion,
maskMotion,
// Private
getTriggerDOMNode,
...restProps
} = props;
const mergedAutoDestroy = autoDestroy || false;
// =========================== Mobile ===========================
const [mobile, setMobile] = React.useState(false);
useLayoutEffect(() => {
setMobile(isMobile());
}, []);
// ========================== Context ===========================
const subPopupElements = React.useRef({});
const parentContext = React.useContext(TriggerContext);
const context = React.useMemo(() => {
return {
registerSubPopup: (id, subPopupEle) => {
subPopupElements.current[id] = subPopupEle;
parentContext?.registerSubPopup(id, subPopupEle);
}
};
}, [parentContext]);
// =========================== Popup ============================
const id = useId();
const [popupEle, setPopupEle] = React.useState(null);
// Used for forwardRef popup. Not use internal
const externalPopupRef = React.useRef(null);
const setPopupRef = useEvent(node => {
externalPopupRef.current = node;
if (isDOM(node) && popupEle !== node) {
setPopupEle(node);
}
parentContext?.registerSubPopup(id, node);
});
// =========================== Target ===========================
// Use state to control here since `useRef` update not trigger render
const [targetEle, setTargetEle] = React.useState(null);
// Used for forwardRef target. Not use internal
const externalForwardRef = React.useRef(null);
const setTargetRef = useEvent(node => {
if (isDOM(node) && targetEle !== node) {
setTargetEle(node);
externalForwardRef.current = node;
}
});
// ========================== Children ==========================
const child = React.Children.only(children);
const originChildProps = child?.props || {};
const cloneProps = {};
const inPopupOrChild = useEvent(ele => {
const childDOM = targetEle;
return childDOM?.contains(ele) || getShadowRoot(childDOM)?.host === ele || ele === childDOM || popupEle?.contains(ele) || getShadowRoot(popupEle)?.host === ele || ele === popupEle || Object.values(subPopupElements.current).some(subPopupEle => subPopupEle?.contains(ele) || ele === subPopupEle);
});
// ============================ Open ============================
const [internalOpen, setInternalOpen] = React.useState(defaultPopupVisible || false);
// Render still use props as first priority
const mergedOpen = popupVisible ?? internalOpen;
// We use effect sync here in case `popupVisible` back to `undefined`
const setMergedOpen = useEvent(nextOpen => {
if (popupVisible === undefined) {
setInternalOpen(nextOpen);
}
});
useLayoutEffect(() => {
setInternalOpen(popupVisible || false);
}, [popupVisible]);
const openRef = React.useRef(mergedOpen);
openRef.current = mergedOpen;
const lastTriggerRef = React.useRef([]);
lastTriggerRef.current = [];
const internalTriggerOpen = useEvent(nextOpen => {
setMergedOpen(nextOpen);
// Enter or Pointer will both trigger open state change
// We only need take one to avoid duplicated change event trigger
// Use `lastTriggerRef` to record last open type
if ((lastTriggerRef.current[lastTriggerRef.current.length - 1] ?? mergedOpen) !== nextOpen) {
lastTriggerRef.current.push(nextOpen);
onOpenChange?.(nextOpen);
onPopupVisibleChange?.(nextOpen);
}
});
// Trigger for delay
const delayRef = React.useRef();
const clearDelay = () => {
clearTimeout(delayRef.current);
};
const triggerOpen = (nextOpen, delay = 0) => {
clearDelay();
if (delay === 0) {
internalTriggerOpen(nextOpen);
} else {
delayRef.current = setTimeout(() => {
internalTriggerOpen(nextOpen);
}, delay * 1000);
}
};
React.useEffect(() => clearDelay, []);
// ========================== Motion ============================
const [inMotion, setInMotion] = React.useState(false);
useLayoutEffect(firstMount => {
if (!firstMount || mergedOpen) {
setInMotion(true);
}
}, [mergedOpen]);
const [motionPrepareResolve, setMotionPrepareResolve] = React.useState(null);
// =========================== Align ============================
const [mousePos, setMousePos] = React.useState(null);
const setMousePosByEvent = event => {
setMousePos([event.clientX, event.clientY]);
};
const [ready, offsetX, offsetY, offsetR, offsetB, arrowX, arrowY, scaleX, scaleY, alignInfo, onAlign] = useAlign(mergedOpen, popupEle, alignPoint && mousePos !== null ? mousePos : targetEle, popupPlacement, builtinPlacements, popupAlign, onPopupAlign);
const [showActions, hideActions] = useAction(mobile, action, showAction, hideAction);
const clickToShow = showActions.has('click');
const clickToHide = hideActions.has('click') || hideActions.has('contextMenu');
const triggerAlign = useEvent(() => {
if (!inMotion) {
onAlign();
}
});
const onScroll = () => {
if (openRef.current && alignPoint && clickToHide) {
triggerOpen(false);
}
};
useWatch(mergedOpen, targetEle, popupEle, triggerAlign, onScroll);
useLayoutEffect(() => {
triggerAlign();
}, [mousePos, popupPlacement]);
// When no builtinPlacements and popupAlign changed
useLayoutEffect(() => {
if (mergedOpen && !builtinPlacements?.[popupPlacement]) {
triggerAlign();
}
}, [JSON.stringify(popupAlign)]);
const alignedClassName = React.useMemo(() => {
const baseClassName = getAlignPopupClassName(builtinPlacements, prefixCls, alignInfo, alignPoint);
return classNames(baseClassName, getPopupClassNameFromAlign?.(alignInfo));
}, [alignInfo, getPopupClassNameFromAlign, builtinPlacements, prefixCls, alignPoint]);
// ============================ Refs ============================
React.useImperativeHandle(ref, () => ({
nativeElement: externalForwardRef.current,
popupElement: externalPopupRef.current,
forceAlign: triggerAlign
}));
// ========================== Stretch ===========================
const [targetWidth, setTargetWidth] = React.useState(0);
const [targetHeight, setTargetHeight] = React.useState(0);
const syncTargetSize = () => {
if (stretch && targetEle) {
const rect = targetEle.getBoundingClientRect();
setTargetWidth(rect.width);
setTargetHeight(rect.height);
}
};
const onTargetResize = () => {
syncTargetSize();
triggerAlign();
};
// ========================== Motion ============================
const onVisibleChanged = visible => {
setInMotion(false);
onAlign();
afterOpenChange?.(visible);
afterPopupVisibleChange?.(visible);
};
// We will trigger align when motion is in prepare
const onPrepare = () => new Promise(resolve => {
syncTargetSize();
setMotionPrepareResolve(() => resolve);
});
useLayoutEffect(() => {
if (motionPrepareResolve) {
onAlign();
motionPrepareResolve();
setMotionPrepareResolve(null);
}
}, [motionPrepareResolve]);
// =========================== Action ===========================
/**
* Util wrapper for trigger action
*/
function wrapperAction(eventName, nextOpen, delay, preEvent) {
cloneProps[eventName] = (event, ...args) => {
preEvent?.(event);
triggerOpen(nextOpen, delay);
// Pass to origin
originChildProps[eventName]?.(event, ...args);
};
}
// ======================= Action: Click ========================
if (clickToShow || clickToHide) {
cloneProps.onClick = (event, ...args) => {
if (openRef.current && clickToHide) {
triggerOpen(false);
} else if (!openRef.current && clickToShow) {
setMousePosByEvent(event);
triggerOpen(true);
}
// Pass to origin
originChildProps.onClick?.(event, ...args);
};
}
// Click to hide is special action since click popup element should not hide
const onPopupPointerDown = useWinClick(mergedOpen, clickToHide, targetEle, popupEle, mask, maskClosable, inPopupOrChild, triggerOpen);
// ======================= Action: Hover ========================
const hoverToShow = showActions.has('hover');
const hoverToHide = hideActions.has('hover');
let onPopupMouseEnter;
let onPopupMouseLeave;
if (hoverToShow) {
// Compatible with old browser which not support pointer event
wrapperAction('onMouseEnter', true, mouseEnterDelay, event => {
setMousePosByEvent(event);
});
wrapperAction('onPointerEnter', true, mouseEnterDelay, event => {
setMousePosByEvent(event);
});
onPopupMouseEnter = event => {
// Only trigger re-open when popup is visible
if ((mergedOpen || inMotion) && popupEle?.contains(event.target)) {
triggerOpen(true, mouseEnterDelay);
}
};
// Align Point
if (alignPoint) {
cloneProps.onMouseMove = event => {
// setMousePosByEvent(event);
originChildProps.onMouseMove?.(event);
};
}
}
if (hoverToHide) {
wrapperAction('onMouseLeave', false, mouseLeaveDelay);
wrapperAction('onPointerLeave', false, mouseLeaveDelay);
onPopupMouseLeave = () => {
triggerOpen(false, mouseLeaveDelay);
};
}
// ======================= Action: Focus ========================
if (showActions.has('focus')) {
wrapperAction('onFocus', true, focusDelay);
}
if (hideActions.has('focus')) {
wrapperAction('onBlur', false, blurDelay);
}
// ==================== Action: ContextMenu =====================
if (showActions.has('contextMenu')) {
cloneProps.onContextMenu = (event, ...args) => {
if (openRef.current && hideActions.has('contextMenu')) {
triggerOpen(false);
} else {
setMousePosByEvent(event);
triggerOpen(true);
}
event.preventDefault();
// Pass to origin
originChildProps.onContextMenu?.(event, ...args);
};
}
// =========================== Render ===========================
const mergedChildrenProps = {
...originChildProps,
...cloneProps
};
// Pass props into cloneProps for nest usage
const passedProps = {};
const passedEventList = ['onContextMenu', 'onClick', 'onMouseDown', 'onTouchStart', 'onMouseEnter', 'onMouseLeave', 'onFocus', 'onBlur'];
passedEventList.forEach(eventName => {
if (restProps[eventName]) {
passedProps[eventName] = (...args) => {
mergedChildrenProps[eventName]?.(...args);
restProps[eventName](...args);
};
}
});
// Child Node
const triggerNode = /*#__PURE__*/React.cloneElement(child, {
...mergedChildrenProps,
...passedProps
});
const arrowPos = {
x: arrowX,
y: arrowY
};
const innerArrow = arrow ? {
// true and Object likely
...(arrow !== true ? arrow : {})
} : null;
// Render
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ResizeObserver, {
disabled: !mergedOpen,
ref: setTargetRef,
onResize: onTargetResize
}, /*#__PURE__*/React.createElement(TriggerWrapper, {
getTriggerDOMNode: getTriggerDOMNode
}, triggerNode)), /*#__PURE__*/React.createElement(TriggerContext.Provider, {
value: context
}, /*#__PURE__*/React.createElement(Popup, {
portal: PortalComponent,
ref: setPopupRef,
prefixCls: prefixCls,
popup: popup,
className: classNames(popupClassName, alignedClassName),
style: popupStyle,
target: targetEle,
onMouseEnter: onPopupMouseEnter,
onMouseLeave: onPopupMouseLeave
// https://github.com/ant-design/ant-design/issues/43924
,
onPointerEnter: onPopupMouseEnter,
zIndex: zIndex
// Open
,
open: mergedOpen,
keepDom: inMotion,
fresh: fresh
// Click
,
onClick: onPopupClick,
onPointerDownCapture: onPopupPointerDown
// Mask
,
mask: mask
// Motion
,
motion: popupMotion,
maskMotion: maskMotion,
onVisibleChanged: onVisibleChanged,
onPrepare: onPrepare
// Portal
,
forceRender: forceRender,
autoDestroy: mergedAutoDestroy,
getPopupContainer: getPopupContainer
// Arrow
,
align: alignInfo,
arrow: innerArrow,
arrowPos: arrowPos
// Align
,
ready: ready,
offsetX: offsetX,
offsetY: offsetY,
offsetR: offsetR,
offsetB: offsetB,
onAlign: triggerAlign
// Stretch
,
stretch: stretch,
targetWidth: targetWidth / scaleX,
targetHeight: targetHeight / scaleY
})));
});
if (process.env.NODE_ENV !== 'production') {
Trigger.displayName = 'Trigger';
}
return Trigger;
}
export default generateTrigger(Portal);