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
+ >
+ Right Button
+
+
+
+
+
+ setUseUniqueProvider(e.target.checked)}
+ />
+ 使用 UniqueProvider
+
+
+
+
+
+ );
+
+ 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);
+ });
+});