diff --git a/assets/index.less b/assets/index.less index e13a1256..d7ffee51 100644 --- a/assets/index.less +++ b/assets/index.less @@ -73,6 +73,54 @@ opacity: 0; } } + + // =============== Float BG =============== + &-float-bg { + position: absolute; + z-index: 0; + box-sizing: border-box; + border: 1px solid red; + background: green; + + &-hidden { + display: none; + } + + &-visible { + transition: all 0.1s; + } + } + + // Debug + &-unique-controlled { + border-color: rgba(0, 0, 0, 0.01) !important; + background: transparent !important; + z-index: 1; + } + + // Motion Content + &-motion-content { + // Fade motion + &-fade-appear { + opacity: 0; + animation-duration: 0.3s; + animation-fill-mode: both; + animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); + } + + &-fade-appear&-fade-appear-active { + animation-name: rcTriggerFadeIn; + } + + @keyframes rcTriggerFadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + } } @import './index/Mask'; diff --git a/docs/demos/two-buttons.md b/docs/demos/two-buttons.md new file mode 100644 index 00000000..299a7a19 --- /dev/null +++ b/docs/demos/two-buttons.md @@ -0,0 +1,8 @@ +--- +title: Moving Popup +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx new file mode 100644 index 00000000..8b18c2ee --- /dev/null +++ b/docs/examples/two-buttons.tsx @@ -0,0 +1,140 @@ +import Trigger, { UniqueProvider } from '@rc-component/trigger'; +import React, { useState } from 'react'; +import '../../assets/index.less'; + +const LEAVE_DELAY = 0.2; + +const builtinPlacements = { + left: { + points: ['cr', 'cl'], + offset: [-10, 0], + }, + right: { + points: ['cl', 'cr'], + offset: [10, 0], + }, + top: { + points: ['bc', 'tc'], + offset: [0, -10], + }, + bottom: { + points: ['tc', 'bc'], + offset: [0, 10], + }, +}; + +const MovingPopupDemo = () => { + const [useUniqueProvider, setUseUniqueProvider] = useState(true); + const [triggerControl, setTriggerControl] = useState('none'); // 'button1', 'button2', 'none' + + const getVisible = (name: string) => { + if (triggerControl === 'none') { + return undefined; + } + if (triggerControl === name) { + return true; + } + return false; + }; + + const content = ( +
+
+ 这是左侧按钮的提示信息
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + unique + > + + + + This is the tooltip for the right button
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + unique + > + + + + +
+ +
+ +
+
Trigger 控制:
+ + + +
+ + ); + + return useUniqueProvider ? ( + {content} + ) : ( + content + ); +}; + +export default MovingPopupDemo; diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 37e43ce5..8fdcdc93 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -1,7 +1,9 @@ import classNames from 'classnames'; import type { CSSMotionProps } from '@rc-component/motion'; import CSSMotion from '@rc-component/motion'; -import ResizeObserver from '@rc-component/resize-observer'; +import ResizeObserver, { + type ResizeObserverProps, +} from '@rc-component/resize-observer'; import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; import { composeRef } from '@rc-component/util/lib/ref'; import * as React from 'react'; @@ -10,6 +12,8 @@ import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface'; import Arrow from './Arrow'; import Mask from './Mask'; import PopupContent from './PopupContent'; +import useOffsetStyle from '../hooks/useOffsetStyle'; +import { useEvent } from '@rc-component/util'; export interface MobileConfig { mask?: boolean; @@ -58,6 +62,8 @@ export interface PopupProps { autoDestroy?: boolean; portal: React.ComponentType; + children?: React.ReactElement; + // Align ready: boolean; offsetX: number; @@ -72,6 +78,9 @@ export interface PopupProps { targetWidth?: number; targetHeight?: number; + // Resize + onResize?: ResizeObserverProps['onResize']; + // Mobile mobile?: MobileConfig; } @@ -114,6 +123,7 @@ const Popup = React.forwardRef((props, ref) => { getPopupContainer, autoDestroy, portal: Portal, + children, zIndex, @@ -130,12 +140,15 @@ const Popup = React.forwardRef((props, ref) => { onAlign, onPrepare, + // Resize + onResize, + stretch, targetWidth, targetHeight, } = props; - const childNode = typeof popup === 'function' ? popup() : popup; + const popupContent = typeof popup === 'function' ? popup() : popup; // We can not remove holder only when motion finished. const isNodeVisible = open || keepDom; @@ -172,48 +185,31 @@ const Popup = React.forwardRef((props, ref) => { } }, [show, getPopupContainerNeedParams, target]); + // ========================= Resize ========================= + const onInternalResize: ResizeObserverProps['onResize'] = useEvent( + (size, ele) => { + onResize?.(size, ele); + onAlign(); + }, + ); + + // ========================= Styles ========================= + const offsetStyle = useOffsetStyle( + isMobile, + ready, + open, + align, + offsetR, + offsetB, + offsetX, + offsetY, + ); + // ========================= Render ========================= if (!show) { return null; } - // >>>>> Offset - const AUTO = 'auto' as const; - - const offsetStyle: React.CSSProperties = isMobile - ? {} - : { - left: '-1000vw', - top: '-1000vh', - right: AUTO, - bottom: AUTO, - }; - - // Set align style - if (!isMobile && (ready || !open)) { - const { points } = align; - const dynamicInset = - align.dynamicInset || (align as any)._experimental?.dynamicInset; - const alignRight = dynamicInset && points[0][1] === 'r'; - const alignBottom = dynamicInset && points[0][0] === 'b'; - - if (alignRight) { - offsetStyle.right = offsetR; - offsetStyle.left = AUTO; - } else { - offsetStyle.left = offsetX; - offsetStyle.right = AUTO; - } - - if (alignBottom) { - offsetStyle.bottom = offsetB; - offsetStyle.top = AUTO; - } else { - offsetStyle.top = offsetY; - offsetStyle.bottom = AUTO; - } - } - // >>>>> Misc const miscStyle: React.CSSProperties = {}; if (stretch) { @@ -247,7 +243,7 @@ const Popup = React.forwardRef((props, ref) => { motion={mergedMaskMotion} mobile={isMobile} /> - + {(resizeObserverRef) => { return ( ((props, ref) => { /> )} - {childNode} + {popupContent} ); @@ -314,6 +310,7 @@ const Popup = React.forwardRef((props, ref) => { ); }} + {children} ); }); diff --git a/src/UniqueProvider/FloatBg.tsx b/src/UniqueProvider/FloatBg.tsx new file mode 100644 index 00000000..1a7b9e05 --- /dev/null +++ b/src/UniqueProvider/FloatBg.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import useOffsetStyle from '../hooks/useOffsetStyle'; +import classNames from 'classnames'; +import CSSMotion from '@rc-component/motion'; +import type { CSSMotionProps } from '@rc-component/motion'; +import type { AlignType } from '../interface'; + +export interface FloatBgProps { + prefixCls: string; // ${prefixCls}-float-bg + isMobile: boolean; + ready: boolean; + open: boolean; + align: AlignType; + offsetR: number; + offsetB: number; + offsetX: number; + offsetY: number; + popupSize?: { width: number; height: number }; + motion?: CSSMotionProps; +} + +const FloatBg = (props: FloatBgProps) => { + const { + prefixCls, + isMobile, + ready, + open, + align, + offsetR, + offsetB, + offsetX, + offsetY, + popupSize, + motion, + } = props; + + const floatBgCls = `${prefixCls}-float-bg`; + + const [motionVisible, setMotionVisible] = React.useState(false); + + // ========================= Styles ========================= + const offsetStyle = useOffsetStyle( + isMobile, + ready, + open, + align, + offsetR, + offsetB, + offsetX, + offsetY, + ); + + // Apply popup size if available + const sizeStyle: React.CSSProperties = {}; + if (popupSize) { + sizeStyle.width = popupSize.width; + sizeStyle.height = popupSize.height; + } + + // ========================= Render ========================= + return ( + { + setMotionVisible(nextVisible); + }} + > + {({ className: motionClassName, style: motionStyle }) => { + const cls = classNames(floatBgCls, motionClassName, { + [`${floatBgCls}-visible`]: motionVisible, + }); + + return ( +
+ ); + }} + + ); +}; + +export default FloatBg; diff --git a/src/UniqueProvider/MotionContent.tsx b/src/UniqueProvider/MotionContent.tsx new file mode 100644 index 00000000..fba5f549 --- /dev/null +++ b/src/UniqueProvider/MotionContent.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import type { TriggerProps } from '..'; +import CSSMotion from '@rc-component/motion'; +import classNames from 'classnames'; + +export interface MotionContentProps { + prefixCls: string; // ${prefixCls}-motion-content apply on root div + children: TriggerProps['popup']; +} + +const MotionContent = (props: MotionContentProps) => { + const { prefixCls, children } = props; + + const childNode = typeof children === 'function' ? children() : children; + + // motion name: `${prefixCls}-motion-content-fade`, apply in index.less + const motionName = `${prefixCls}-motion-content-fade`; + + return ( + + {({ className: motionClassName, style: motionStyle }) => { + const cls = classNames(`${prefixCls}-motion-content`, motionClassName); + + return ( +
+ {childNode} +
+ ); + }} +
+ ); +}; + +if (process.env.NODE_ENV !== 'production') { + MotionContent.displayName = 'MotionContent'; +} + +export default MotionContent; diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx new file mode 100644 index 00000000..6fc9a0c9 --- /dev/null +++ b/src/UniqueProvider/index.tsx @@ -0,0 +1,198 @@ +import * as React from 'react'; +import Portal from '@rc-component/portal'; +import TriggerContext, { + UniqueContext, + type UniqueContextProps, + type TriggerContextProps, + type UniqueShowOptions, +} from '../context'; +import useDelay from '../hooks/useDelay'; +import useAlign from '../hooks/useAlign'; +import Popup from '../Popup'; +import { useEvent } from '@rc-component/util'; +import useTargetState from './useTargetState'; +import { isDOM } from '@rc-component/util/lib/Dom/findDOMNode'; +import FloatBg from './FloatBg'; +import classNames from 'classnames'; +import MotionContent from './MotionContent'; + +export interface UniqueProviderProps { + children: React.ReactNode; +} + +const UniqueProvider = ({ children }: UniqueProviderProps) => { + const [trigger, open, options, onTargetVisibleChanged] = useTargetState(); + + // =========================== Popup ============================ + const [popupEle, setPopupEle] = React.useState(null); + const [popupSize, setPopupSize] = React.useState<{ + width: number; + height: number; + }>(null); + + // Used for forwardRef popup. Not use internal + const externalPopupRef = React.useRef(null); + + const setPopupRef = useEvent((node: HTMLDivElement) => { + externalPopupRef.current = node; + + if (isDOM(node) && popupEle !== node) { + setPopupEle(node); + } + }); + + // ========================== Register ========================== + const [popupId, setPopupId] = React.useState(0); + + const delayInvoke = useDelay(); + + const show = useEvent((showOptions: UniqueShowOptions) => { + delayInvoke(() => { + if (showOptions.id !== options?.id) { + setPopupId((i) => i + 1); + } + trigger(showOptions); + }, showOptions.delay); + }); + + const hide = (delay: number) => { + delayInvoke(() => { + trigger(false); + // Don't clear target, currentNode, options immediately, wait until animation completes + }, delay); + }; + + // Callback after animation completes + const onVisibleChanged = useEvent((visible: boolean) => { + // Call useTargetState callback to handle animation state + onTargetVisibleChanged(visible); + }); + + // =========================== Align ============================ + const [ + ready, + offsetX, + offsetY, + offsetR, + offsetB, + arrowX, + arrowY, // scaleX - not used in UniqueProvider + , + , + // scaleY - not used in UniqueProvider + alignInfo, + onAlign, + ] = useAlign( + open, + popupEle, + options?.target, + options?.popupPlacement, + options?.builtinPlacements || {}, + options?.popupAlign, + undefined, // onPopupAlign + false, // isMobile + ); + + const contextValue = React.useMemo( + () => ({ + show, + hide, + }), + [], + ); + + // =========================== Motion =========================== + const onPrepare = useEvent(() => { + onAlign(); + + return Promise.resolve(); + }); + + // ======================== Trigger Context ===================== + const subPopupElements = React.useRef>({}); + const parentContext = React.useContext(TriggerContext); + + const triggerContextValue = React.useMemo( + () => ({ + registerSubPopup: (id, subPopupEle) => { + subPopupElements.current[id] = subPopupEle; + parentContext?.registerSubPopup(id, subPopupEle); + }, + }), + [parentContext], + ); + + // =========================== Render =========================== + const prefixCls = options?.prefixCls; + + return ( + + {children} + {options && ( + + + {options.popup} + + } + className={classNames( + options.popupClassName, + `${prefixCls}-unique-controlled`, + )} + style={options.popupStyle} + target={options.target} + open={open} + keepDom={true} + fresh={true} + autoDestroy={false} + onVisibleChanged={onVisibleChanged} + ready={ready} + offsetX={offsetX} + offsetY={offsetY} + offsetR={offsetR} + offsetB={offsetB} + onAlign={onAlign} + onPrepare={onPrepare} + onResize={(size) => + setPopupSize({ + width: size.offsetWidth, + height: size.offsetHeight, + }) + } + arrowPos={{ + x: arrowX, + y: arrowY, + }} + align={alignInfo} + zIndex={options.zIndex} + mask={options.mask} + arrow={options.arrow} + motion={options.popupMotion} + maskMotion={options.maskMotion} + getPopupContainer={options.getPopupContainer} + > + + + + )} + + ); +}; + +export default UniqueProvider; diff --git a/src/UniqueProvider/useTargetState.ts b/src/UniqueProvider/useTargetState.ts new file mode 100644 index 00000000..04c39c9a --- /dev/null +++ b/src/UniqueProvider/useTargetState.ts @@ -0,0 +1,66 @@ +import React from 'react'; +import { useEvent } from '@rc-component/util'; +import type { UniqueShowOptions } from '../context'; + +/** + * Control the state of popup bind target: + * 1. When set `target`. Do show the popup. + * 2. When `target` is removed. Do hide the popup. + * 3. When `target` change to another one: + * a. We wait motion finish of previous popup. + * b. Then we set new target and show the popup. + * 4. During appear/enter animation, cache new options and apply after animation completes. + */ +export default function useTargetState(): [ + trigger: (options: UniqueShowOptions | false) => void, + open: boolean, + /* Will always cache last which is not null */ + cacheOptions: UniqueShowOptions | null, + onVisibleChanged: (visible: boolean) => void, +] { + const [options, setOptions] = React.useState(null); + const [open, setOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + const pendingOptionsRef = React.useRef(null); + + const trigger = useEvent((nextOptions: UniqueShowOptions | false) => { + if (nextOptions === false) { + // Clear pending options when hiding + pendingOptionsRef.current = null; + setOpen(false); + } else { + if (isAnimating && open) { + // If animating (appear or enter), cache new options + pendingOptionsRef.current = nextOptions; + } else { + setOpen(true); + // Set new options + setOptions(nextOptions); + pendingOptionsRef.current = null; + + // Only mark as animating when transitioning from closed to open + if (!open) { + setIsAnimating(true); + } + } + } + }); + + const onVisibleChanged = useEvent((visible: boolean) => { + if (visible) { + // Animation enter completed, check if there are pending options + setIsAnimating(false); + if (pendingOptionsRef.current) { + // Apply pending options + setOptions(pendingOptionsRef.current); + pendingOptionsRef.current = null; + } + } else { + // Animation leave completed + setIsAnimating(false); + pendingOptionsRef.current = null; + } + }); + + return [trigger, open, options, onVisibleChanged]; +} diff --git a/src/context.ts b/src/context.ts index 429b350f..98b77e3a 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,5 +1,9 @@ import * as React from 'react'; +import type { CSSMotionProps } from '@rc-component/motion'; +import type { TriggerProps } from './index'; +import type { AlignType, ArrowTypeOuter, BuildInPlacements } from './interface'; +// ===================== Nest ===================== export interface TriggerContextProps { registerSubPopup: (id: string, node: HTMLElement) => void; } @@ -7,3 +11,33 @@ export interface TriggerContextProps { const TriggerContext = React.createContext(null); export default TriggerContext; + +// ==================== Unique ==================== +export interface UniqueShowOptions { + id: string; + popup: TriggerProps['popup']; + target: HTMLElement; + delay: number; + prefixCls?: string; + popupClassName?: string; + popupStyle?: React.CSSProperties; + popupPlacement?: string; + builtinPlacements?: BuildInPlacements; + popupAlign?: AlignType; + zIndex?: number; + mask?: boolean; + maskClosable?: boolean; + popupMotion?: CSSMotionProps; + maskMotion?: CSSMotionProps; + arrow?: ArrowTypeOuter; + getPopupContainer?: TriggerProps['getPopupContainer']; +} + +export interface UniqueContextProps { + show: (options: UniqueShowOptions) => void; + hide: (delay: number) => void; +} + +export const UniqueContext = React.createContext( + null, +); diff --git a/src/hooks/useDelay.ts b/src/hooks/useDelay.ts new file mode 100644 index 00000000..8ac1e54c --- /dev/null +++ b/src/hooks/useDelay.ts @@ -0,0 +1,33 @@ +import * as React from 'react'; + +export default function useDelay() { + const delayRef = React.useRef | null>(null); + + const clearDelay = () => { + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + }; + + const delayInvoke = (callback: VoidFunction, delay: number) => { + clearDelay(); + + if (delay === 0) { + callback(); + } else { + delayRef.current = setTimeout(() => { + callback(); + }, delay * 1000); + } + }; + + // Clean up on unmount + React.useEffect(() => { + return () => { + clearDelay(); + }; + }, []); + + return delayInvoke; +} diff --git a/src/hooks/useOffsetStyle.ts b/src/hooks/useOffsetStyle.ts new file mode 100644 index 00000000..3c590041 --- /dev/null +++ b/src/hooks/useOffsetStyle.ts @@ -0,0 +1,52 @@ +import type * as React from 'react'; +import type { AlignType } from '../interface'; + +export default function useOffsetStyle( + isMobile: boolean, + ready: boolean, + open: boolean, + align: AlignType, + offsetR: number, + offsetB: number, + offsetX: number, + offsetY: number, +) { + // >>>>> Offset + const AUTO = 'auto' as const; + + const offsetStyle: React.CSSProperties = isMobile + ? {} + : { + left: '-1000vw', + top: '-1000vh', + right: AUTO, + bottom: AUTO, + }; + + // Set align style + if (!isMobile && (ready || !open)) { + const { points } = align; + const dynamicInset = + align.dynamicInset || (align as any)._experimental?.dynamicInset; + const alignRight = dynamicInset && points[0][1] === 'r'; + const alignBottom = dynamicInset && points[0][0] === 'b'; + + if (alignRight) { + offsetStyle.right = offsetR; + offsetStyle.left = AUTO; + } else { + offsetStyle.left = offsetX; + offsetStyle.right = AUTO; + } + + if (alignBottom) { + offsetStyle.bottom = offsetB; + offsetStyle.top = AUTO; + } else { + offsetStyle.top = offsetY; + offsetStyle.bottom = AUTO; + } + } + + return offsetStyle; +} diff --git a/src/index.tsx b/src/index.tsx index dafeee78..b7063d63 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,9 +10,10 @@ import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; import * as React from 'react'; import Popup, { type MobileConfig } from './Popup'; import type { TriggerContextProps } from './context'; -import TriggerContext from './context'; +import TriggerContext, { UniqueContext } from './context'; import useAction from './hooks/useAction'; import useAlign from './hooks/useAlign'; +import useDelay from './hooks/useDelay'; import useWatch from './hooks/useWatch'; import useWinClick from './hooks/useWinClick'; import type { @@ -31,6 +32,8 @@ export type { BuildInPlacements, }; +export { default as UniqueProvider } from './UniqueProvider'; + export interface TriggerRef { nativeElement: HTMLElement; popupElement: HTMLDivElement; @@ -108,6 +111,11 @@ export interface TriggerProps { */ fresh?: boolean; + /** + * Config with UniqueProvider to shared the floating popup. + */ + unique?: boolean; + // ==================== Arrow ==================== arrow?: boolean | ArrowTypeOuter; @@ -170,6 +178,7 @@ export function generateTrigger( stretch, getPopupClassNameFromAlign, fresh, + unique, alignPoint, @@ -190,6 +199,7 @@ export function generateTrigger( } = props; const mergedAutoDestroy = autoDestroy || false; + const openUncontrolled = popupVisible === undefined; // =========================== Mobile =========================== const isMobile = !!mobile; @@ -208,6 +218,9 @@ export function generateTrigger( }; }, [parentContext]); + // ======================== UniqueContext ========================= + const uniqueContext = React.useContext(UniqueContext); + // =========================== Popup ============================ const id = useId(); const [popupEle, setPopupEle] = React.useState(null); @@ -273,6 +286,14 @@ export function generateTrigger( ); }); + // =========================== Arrow ============================ + const innerArrow: ArrowTypeOuter = arrow + ? { + // true and Object likely + ...(arrow !== true ? arrow : {}), + } + : null; + // ============================ Open ============================ const [internalOpen, setInternalOpen] = React.useState( defaultPopupVisible || false, @@ -283,7 +304,7 @@ export function generateTrigger( // We use effect sync here in case `popupVisible` back to `undefined` const setMergedOpen = useEvent((nextOpen: boolean) => { - if (popupVisible === undefined) { + if (openUncontrolled) { setInternalOpen(nextOpen); } }); @@ -292,6 +313,41 @@ export function generateTrigger( setInternalOpen(popupVisible || false); }, [popupVisible]); + // Extract common options for UniqueProvider + const getUniqueOptions = useEvent((delay: number = 0) => ({ + popup, + target: targetEle, + delay, + prefixCls, + popupClassName, + popupStyle, + popupPlacement, + builtinPlacements, + popupAlign, + zIndex, + mask, + maskClosable, + popupMotion, + maskMotion, + arrow: innerArrow, + getPopupContainer, + id, + })); + + // Handle controlled state changes for UniqueProvider + // Only sync to UniqueProvider when it's controlled mode + useLayoutEffect(() => { + if (uniqueContext && unique && targetEle && !openUncontrolled) { + if (mergedOpen) { + Promise.resolve().then(() => { + uniqueContext.show(getUniqueOptions(0)); + }); + } else { + uniqueContext.hide(0); + } + } + }, [mergedOpen]); + const openRef = React.useRef(mergedOpen); openRef.current = mergedOpen; @@ -315,25 +371,32 @@ export function generateTrigger( }); // Trigger for delay - const delayRef = React.useRef>(null); - - const clearDelay = () => { - clearTimeout(delayRef.current); - }; + const delayInvoke = useDelay(); const triggerOpen = (nextOpen: boolean, delay = 0) => { - clearDelay(); - - if (delay === 0) { - internalTriggerOpen(nextOpen); - } else { - delayRef.current = setTimeout(() => { + // If it's controlled mode, always use internal trigger logic + // UniqueProvider will be synced through useLayoutEffect + if (popupVisible !== undefined) { + delayInvoke(() => { internalTriggerOpen(nextOpen); - }, delay * 1000); + }, delay); + return; } - }; - React.useEffect(() => clearDelay, []); + // If UniqueContext exists and not controlled, pass delay to Provider instead of handling it internally + if (uniqueContext && unique && openUncontrolled) { + if (nextOpen) { + uniqueContext.show(getUniqueOptions(delay)); + } else { + uniqueContext.hide(delay); + } + return; + } + + delayInvoke(() => { + internalTriggerOpen(nextOpen); + }, delay); + }; // ========================== Motion ============================ const [inMotion, setInMotion] = React.useState(false); @@ -697,13 +760,6 @@ export function generateTrigger( y: arrowY, }; - const innerArrow: ArrowTypeOuter = arrow - ? { - // true and Object likely - ...(arrow !== true ? arrow : {}), - } - : null; - // Child Node const triggerNode = React.cloneElement(child, { ...mergedChildrenProps, @@ -720,7 +776,7 @@ export function generateTrigger( > {triggerNode} - {rendedRef.current && ( + {rendedRef.current && (!uniqueContext || !unique) && ( { + const OriginalFloatBg = jest.requireActual( + '../src/UniqueProvider/FloatBg', + ).default; + const OriginReact = jest.requireActual('react'); + + return (props: any) => { + const { open } = props; + const openRef = OriginReact.useRef(open); + + OriginReact.useEffect(() => { + if (openRef.current !== open) { + global.openChangeLog.push({ from: openRef.current, to: open }); + openRef.current = open; + } + }, [open]); + + return OriginReact.createElement(OriginalFloatBg, props); + }; +}); + +describe('Trigger.Unique', () => { + beforeEach(() => { + jest.useFakeTimers(); + global.openChangeLog = []; + }); + + afterEach(() => { + cleanup(); + jest.useRealTimers(); + }); + + it('moving will not hide the popup', async () => { + const { container } = render( + + tooltip1} + unique + mouseLeaveDelay={0.1} + > +
hover1
+
+ tooltip2} + unique + mouseLeaveDelay={0.1} + > +
hover2
+
+
, + ); + + // Initially no popup should be visible + expect(document.querySelector('.rc-trigger-popup')).toBeFalsy(); + + // Hover first trigger + fireEvent.mouseEnter(container.querySelector('.target1')); + await awaitFakeTimer(); + expect(document.querySelector('.x-content').textContent).toBe('tooltip1'); + expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); + + // Check that popup and float bg are visible + expect(document.querySelector('.rc-trigger-popup').className).not.toContain( + '-hidden', + ); + expect( + document.querySelector('.rc-trigger-popup-float-bg').className, + ).not.toContain('-hidden'); + + // Move from first to second trigger - popup should not hide, but content should change + fireEvent.mouseLeave(container.querySelector('.target1')); + fireEvent.mouseEnter(container.querySelector('.target2')); + + // Wait a short time (less than leave delay) to ensure no close animation is triggered + await awaitFakeTimer(); + + // Popup should still be visible with new content (no close animation) + expect(document.querySelector('.x-content').textContent).toBe('tooltip2'); + expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); + expect(document.querySelector('.rc-trigger-popup').className).not.toContain( + '-hidden', + ); + expect( + document.querySelector('.rc-trigger-popup-float-bg').className, + ).not.toContain('-hidden'); + + // There should only be one popup element + expect(document.querySelectorAll('.rc-trigger-popup').length).toBe(1); + expect(document.querySelectorAll('.rc-trigger-popup-float-bg').length).toBe( + 1, + ); + + // FloatBg open prop should not have changed during transition (no close animation) + expect(global.openChangeLog).toHaveLength(0); + }); +});