UNPKG

@rc-component/trigger

Version:

base abstract trigger component for react

464 lines (428 loc) 15.4 kB
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);