From f9dc1fad128a5adb7ffde27f5211b48431949b16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 10:35:01 +0800 Subject: [PATCH 01/31] chore: init --- docs/demos/two-buttons.md | 8 ++++ docs/examples/two-buttons.tsx | 74 +++++++++++++++++++++++++++++++++++ src/UniqueProvider.tsx | 31 +++++++++++++++ src/context.ts | 11 ++++++ src/index.tsx | 17 +++++++- 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 docs/demos/two-buttons.md create mode 100644 docs/examples/two-buttons.tsx create mode 100644 src/UniqueProvider.tsx 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..f8dd966f --- /dev/null +++ b/docs/examples/two-buttons.tsx @@ -0,0 +1,74 @@ +import Trigger from '@rc-component/trigger'; +import React from 'react'; +import '../../assets/index.less'; + +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], + }, +}; + +function getPopupContainer(trigger) { + return trigger.parentNode; +} + +const MovingPopupDemo = () => { + return ( +
+
+ 这是左侧按钮的提示信息
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + > + + + + This is the tooltip for the right button
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + > + + + + + ); +}; + +export default MovingPopupDemo; diff --git a/src/UniqueProvider.tsx b/src/UniqueProvider.tsx new file mode 100644 index 00000000..a79074c3 --- /dev/null +++ b/src/UniqueProvider.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { UniqueContext, type UniqueContextProps } from './context'; + +export interface UniqueProviderProps { + children: React.ReactNode; +} + +const UniqueProvider = ({ children }: UniqueProviderProps) => { + const [target, setTarget] = React.useState(null); + + const show = (targetElement: HTMLElement) => { + setTarget(targetElement); + }; + + const hide = () => { + setTarget(null); + }; + + const contextValue = React.useMemo(() => ({ + show, + hide, + }), []); + + return ( + + {children} + + ); +}; + +export default UniqueProvider; diff --git a/src/context.ts b/src/context.ts index 429b350f..49bdfbeb 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,5 +1,6 @@ import * as React from 'react'; +// ===================== Nest ===================== export interface TriggerContextProps { registerSubPopup: (id: string, node: HTMLElement) => void; } @@ -7,3 +8,13 @@ export interface TriggerContextProps { const TriggerContext = React.createContext(null); export default TriggerContext; + +// ==================== Unique ==================== +export interface UniqueContextProps { + show: (target: HTMLElement) => void; + hide: (target: HTMLElement) => void; +} + +export const UniqueContext = React.createContext( + null, +); diff --git a/src/index.tsx b/src/index.tsx index dafeee78..5fb24aa8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,8 +9,8 @@ import useId from '@rc-component/util/lib/hooks/useId'; 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 type { TriggerContextProps, UniqueContextProps } from './context'; +import TriggerContext, { UniqueContext } from './context'; import useAction from './hooks/useAction'; import useAlign from './hooks/useAlign'; import useWatch from './hooks/useWatch'; @@ -208,6 +208,9 @@ export function generateTrigger( }; }, [parentContext]); + // ======================== UniqueContext ========================= + const uniqueContext = React.useContext(UniqueContext); + // =========================== Popup ============================ const id = useId(); const [popupEle, setPopupEle] = React.useState(null); @@ -299,6 +302,16 @@ export function generateTrigger( lastTriggerRef.current = []; const internalTriggerOpen = useEvent((nextOpen: boolean) => { + // If UniqueContext exists, delegate show/hide to Provider + if (uniqueContext) { + if (nextOpen && targetEle) { + uniqueContext.show(targetEle); + } else { + uniqueContext.hide(targetEle); + } + return; + } + setMergedOpen(nextOpen); // Enter or Pointer will both trigger open state change From bf0ddfec8b781220854b8d6f8a5b69f940c1bd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 10:50:14 +0800 Subject: [PATCH 02/31] chore: post info --- docs/examples/two-buttons.tsx | 84 ++++++++++++++++++----------------- src/UniqueProvider.tsx | 31 ------------- src/UniqueProvider/index.tsx | 55 +++++++++++++++++++++++ src/context.ts | 4 +- src/index.tsx | 16 +++++-- 5 files changed, 112 insertions(+), 78 deletions(-) delete mode 100644 src/UniqueProvider.tsx create mode 100644 src/UniqueProvider/index.tsx diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index f8dd966f..a9e8743b 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -1,4 +1,4 @@ -import Trigger from '@rc-component/trigger'; +import Trigger, { UniqueProvider } from '@rc-component/trigger'; import React from 'react'; import '../../assets/index.less'; @@ -27,47 +27,49 @@ function getPopupContainer(trigger) { const MovingPopupDemo = () => { return ( -
-
- 这是左侧按钮的提示信息
} - popupStyle={{ - border: '1px solid #ccc', - padding: 10, - background: 'white', - boxSizing: 'border-box', - }} - > - - - - This is the tooltip for the right button
} - popupStyle={{ - border: '1px solid #ccc', - padding: 10, - background: 'white', - boxSizing: 'border-box', - }} - > - - + +
+
+ 这是左侧按钮的提示信息
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + > + + + + This is the tooltip for the right button
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + > + + + - +
); }; diff --git a/src/UniqueProvider.tsx b/src/UniqueProvider.tsx deleted file mode 100644 index a79074c3..00000000 --- a/src/UniqueProvider.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; -import { UniqueContext, type UniqueContextProps } from './context'; - -export interface UniqueProviderProps { - children: React.ReactNode; -} - -const UniqueProvider = ({ children }: UniqueProviderProps) => { - const [target, setTarget] = React.useState(null); - - const show = (targetElement: HTMLElement) => { - setTarget(targetElement); - }; - - const hide = () => { - setTarget(null); - }; - - const contextValue = React.useMemo(() => ({ - show, - hide, - }), []); - - return ( - - {children} - - ); -}; - -export default UniqueProvider; diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx new file mode 100644 index 00000000..d7422084 --- /dev/null +++ b/src/UniqueProvider/index.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { UniqueContext, type UniqueContextProps } from '../context'; + +export interface UniqueProviderProps { + children: React.ReactNode; +} + +const UniqueProvider = ({ children }: UniqueProviderProps) => { + const [target, setTarget] = React.useState(null); + const delayRef = React.useRef | null>(null); + + const clearDelay = () => { + if (delayRef.current) { + clearTimeout(delayRef.current); + delayRef.current = null; + } + }; + + const show = (targetElement: HTMLElement, delay: number) => { + clearDelay(); + + if (delay === 0) { + setTarget(targetElement); + } else { + delayRef.current = setTimeout(() => { + setTarget(targetElement); + }, delay * 1000); + } + }; + + const hide = (targetElement: HTMLElement, delay: number) => { + clearDelay(); + + if (delay === 0) { + setTarget(null); + } else { + delayRef.current = setTimeout(() => { + setTarget(null); + }, delay * 1000); + } + }; + + const contextValue = React.useMemo(() => ({ + show, + hide, + }), []); + + return ( + + {children} + + ); +}; + +export default UniqueProvider; diff --git a/src/context.ts b/src/context.ts index 49bdfbeb..9f6ed109 100644 --- a/src/context.ts +++ b/src/context.ts @@ -11,8 +11,8 @@ export default TriggerContext; // ==================== Unique ==================== export interface UniqueContextProps { - show: (target: HTMLElement) => void; - hide: (target: HTMLElement) => void; + show: (target: HTMLElement, delay: number) => void; + hide: (target: HTMLElement, delay: number) => void; } export const UniqueContext = React.createContext( diff --git a/src/index.tsx b/src/index.tsx index 5fb24aa8..b6d28417 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,7 +9,7 @@ import useId from '@rc-component/util/lib/hooks/useId'; import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; import * as React from 'react'; import Popup, { type MobileConfig } from './Popup'; -import type { TriggerContextProps, UniqueContextProps } from './context'; +import type { TriggerContextProps } from './context'; import TriggerContext, { UniqueContext } from './context'; import useAction from './hooks/useAction'; import useAlign from './hooks/useAlign'; @@ -31,6 +31,8 @@ export type { BuildInPlacements, }; +export { default as UniqueProvider } from './UniqueProvider'; + export interface TriggerRef { nativeElement: HTMLElement; popupElement: HTMLDivElement; @@ -301,13 +303,13 @@ export function generateTrigger( const lastTriggerRef = React.useRef([]); lastTriggerRef.current = []; - const internalTriggerOpen = useEvent((nextOpen: boolean) => { + const internalTriggerOpen = useEvent((nextOpen: boolean, delay: number = 0) => { // If UniqueContext exists, delegate show/hide to Provider if (uniqueContext) { if (nextOpen && targetEle) { - uniqueContext.show(targetEle); + uniqueContext.show(targetEle, delay); } else { - uniqueContext.hide(targetEle); + uniqueContext.hide(targetEle, delay); } return; } @@ -335,6 +337,12 @@ export function generateTrigger( }; const triggerOpen = (nextOpen: boolean, delay = 0) => { + // If UniqueContext exists, pass delay to Provider instead of handling it internally + if (uniqueContext) { + internalTriggerOpen(nextOpen, delay); + return; + } + clearDelay(); if (delay === 0) { From 16935161fc9b66724a43a2e2819f5724c6256ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 10:59:09 +0800 Subject: [PATCH 03/31] chore: use hooks --- src/UniqueProvider/index.tsx | 30 ++++++----------------------- src/hooks/useDelay.ts | 33 ++++++++++++++++++++++++++++++++ src/index.tsx | 37 ++++++++++-------------------------- 3 files changed, 49 insertions(+), 51 deletions(-) create mode 100644 src/hooks/useDelay.ts diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index d7422084..56aed236 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { UniqueContext, type UniqueContextProps } from '../context'; +import useDelay from '../hooks/useDelay'; export interface UniqueProviderProps { children: React.ReactNode; @@ -7,37 +8,18 @@ export interface UniqueProviderProps { const UniqueProvider = ({ children }: UniqueProviderProps) => { const [target, setTarget] = React.useState(null); - const delayRef = React.useRef | null>(null); - - const clearDelay = () => { - if (delayRef.current) { - clearTimeout(delayRef.current); - delayRef.current = null; - } - }; + const delayInvoke = useDelay(); const show = (targetElement: HTMLElement, delay: number) => { - clearDelay(); - - if (delay === 0) { + delayInvoke(() => { setTarget(targetElement); - } else { - delayRef.current = setTimeout(() => { - setTarget(targetElement); - }, delay * 1000); - } + }, delay); }; const hide = (targetElement: HTMLElement, delay: number) => { - clearDelay(); - - if (delay === 0) { + delayInvoke(() => { setTarget(null); - } else { - delayRef.current = setTimeout(() => { - setTarget(null); - }, delay * 1000); - } + }, delay); }; const contextValue = React.useMemo(() => ({ diff --git a/src/hooks/useDelay.ts b/src/hooks/useDelay.ts new file mode 100644 index 00000000..fde12869 --- /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: () => void, 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/index.tsx b/src/index.tsx index b6d28417..27b496d1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -13,6 +13,7 @@ import type { TriggerContextProps } 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 { @@ -303,17 +304,7 @@ export function generateTrigger( const lastTriggerRef = React.useRef([]); lastTriggerRef.current = []; - const internalTriggerOpen = useEvent((nextOpen: boolean, delay: number = 0) => { - // If UniqueContext exists, delegate show/hide to Provider - if (uniqueContext) { - if (nextOpen && targetEle) { - uniqueContext.show(targetEle, delay); - } else { - uniqueContext.hide(targetEle, delay); - } - return; - } - + const internalTriggerOpen = useEvent((nextOpen: boolean) => { setMergedOpen(nextOpen); // Enter or Pointer will both trigger open state change @@ -330,32 +321,24 @@ 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) => { // If UniqueContext exists, pass delay to Provider instead of handling it internally if (uniqueContext) { - internalTriggerOpen(nextOpen, delay); + if (nextOpen && targetEle) { + uniqueContext.show(targetEle, delay); + } else { + uniqueContext.hide(targetEle, delay); + } return; } - clearDelay(); - - if (delay === 0) { + delayInvoke(() => { internalTriggerOpen(nextOpen); - } else { - delayRef.current = setTimeout(() => { - internalTriggerOpen(nextOpen); - }, delay * 1000); - } + }, delay); }; - React.useEffect(() => clearDelay, []); - // ========================== Motion ============================ const [inMotion, setInMotion] = React.useState(false); From 3c96456a787d1547c8c323f62275a27b6964483e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 11:32:26 +0800 Subject: [PATCH 04/31] chore: api update --- src/UniqueProvider/index.tsx | 21 +++++++++++++++------ src/context.ts | 5 +++-- src/hooks/useDelay.ts | 4 ++-- src/index.tsx | 6 +++--- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 56aed236..28d01e08 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -8,25 +8,34 @@ export interface UniqueProviderProps { const UniqueProvider = ({ children }: UniqueProviderProps) => { const [target, setTarget] = React.useState(null); + const [currentNode, setCurrentNode] = React.useState(null); + + // ========================== Register ========================== const delayInvoke = useDelay(); - const show = (targetElement: HTMLElement, delay: number) => { + const show = (node: React.ReactNode, targetElement: HTMLElement, delay: number) => { delayInvoke(() => { + setCurrentNode(node); setTarget(targetElement); }, delay); }; - const hide = (targetElement: HTMLElement, delay: number) => { + const hide = (delay: number) => { delayInvoke(() => { setTarget(null); + setCurrentNode(null); }, delay); }; - const contextValue = React.useMemo(() => ({ - show, - hide, - }), []); + const contextValue = React.useMemo( + () => ({ + show, + hide, + }), + [], + ); + // =========================== Render =========================== return ( {children} diff --git a/src/context.ts b/src/context.ts index 9f6ed109..701786d6 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { TriggerProps } from './index'; // ===================== Nest ===================== export interface TriggerContextProps { @@ -11,8 +12,8 @@ export default TriggerContext; // ==================== Unique ==================== export interface UniqueContextProps { - show: (target: HTMLElement, delay: number) => void; - hide: (target: HTMLElement, delay: number) => void; + show: (popup: TriggerProps['popup'], target: HTMLElement, delay: number) => void; + hide: (delay: number) => void; } export const UniqueContext = React.createContext( diff --git a/src/hooks/useDelay.ts b/src/hooks/useDelay.ts index fde12869..8ac1e54c 100644 --- a/src/hooks/useDelay.ts +++ b/src/hooks/useDelay.ts @@ -10,9 +10,9 @@ export default function useDelay() { } }; - const delayInvoke = (callback: () => void, delay: number) => { + const delayInvoke = (callback: VoidFunction, delay: number) => { clearDelay(); - + if (delay === 0) { callback(); } else { diff --git a/src/index.tsx b/src/index.tsx index 27b496d1..88d6b1f5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -327,9 +327,9 @@ export function generateTrigger( // If UniqueContext exists, pass delay to Provider instead of handling it internally if (uniqueContext) { if (nextOpen && targetEle) { - uniqueContext.show(targetEle, delay); + uniqueContext.show(popup, targetEle, delay); } else { - uniqueContext.hide(targetEle, delay); + uniqueContext.hide(delay); } return; } @@ -724,7 +724,7 @@ export function generateTrigger( > {triggerNode} - {rendedRef.current && ( + {rendedRef.current && !uniqueContext && ( Date: Fri, 12 Sep 2025 11:55:33 +0800 Subject: [PATCH 05/31] chore: comment --- src/UniqueProvider/index.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 28d01e08..be821c9c 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -35,6 +35,14 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { [], ); + /** + * TODO: 参考 index.tsx 的逻辑,增加 Popup 支持: + * + From 592d0a3ba0028647b358c7e044f9d9cd9ee7d9ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 11:56:32 +0800 Subject: [PATCH 06/31] chore: comment --- src/UniqueProvider/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index be821c9c..1f5305d8 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -41,6 +41,8 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { Date: Fri, 12 Sep 2025 14:27:32 +0800 Subject: [PATCH 07/31] chore: wrapper --- docs/examples/two-buttons.tsx | 100 +++++++++++++++++++--------------- src/UniqueProvider/index.tsx | 80 ++++++++++++++++++++++----- src/context.ts | 21 ++++++- src/index.tsx | 19 ++++++- 4 files changed, 159 insertions(+), 61 deletions(-) diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index a9e8743b..ebb40a43 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -1,5 +1,5 @@ import Trigger, { UniqueProvider } from '@rc-component/trigger'; -import React from 'react'; +import React, { useState } from 'react'; import '../../assets/index.less'; const builtinPlacements = { @@ -26,51 +26,63 @@ function getPopupContainer(trigger) { } const MovingPopupDemo = () => { - return ( - -
-
- 这是左侧按钮的提示信息
} - popupStyle={{ - border: '1px solid #ccc', - padding: 10, - background: 'white', - boxSizing: 'border-box', - }} - > - - - - This is the tooltip for the right button
} - popupStyle={{ - border: '1px solid #ccc', - padding: 10, - background: 'white', - boxSizing: 'border-box', - }} - > - - - + const [useUniqueProvider, setUseUniqueProvider] = useState(true); + + const content = ( +
+
+
- +
+ 这是左侧按钮的提示信息
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + > + + + + This is the tooltip for the right button
} + popupStyle={{ + border: '1px solid #ccc', + padding: 10, + background: 'white', + boxSizing: 'border-box', + }} + > + + + + ); + + return useUniqueProvider ? {content} : content; }; export default MovingPopupDemo; diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 1f5305d8..da1183b0 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -1,29 +1,39 @@ import * as React from 'react'; -import { UniqueContext, type UniqueContextProps } from '../context'; +import Portal from '@rc-component/portal'; +import TriggerContext, { + UniqueContext, + type UniqueContextProps, + type TriggerContextProps, + type UniqueShowOptions, +} from '../context'; import useDelay from '../hooks/useDelay'; +import Popup from '../Popup'; export interface UniqueProviderProps { children: React.ReactNode; } const UniqueProvider = ({ children }: UniqueProviderProps) => { + const [open, setOpen] = React.useState(false); const [target, setTarget] = React.useState(null); const [currentNode, setCurrentNode] = React.useState(null); + const [options, setOptions] = React.useState(null); // ========================== Register ========================== const delayInvoke = useDelay(); - const show = (node: React.ReactNode, targetElement: HTMLElement, delay: number) => { + const show = (showOptions: UniqueShowOptions) => { delayInvoke(() => { - setCurrentNode(node); - setTarget(targetElement); - }, delay); + setOpen(true); + setCurrentNode(showOptions.popup); + setTarget(showOptions.target); + setOptions(showOptions); + }, showOptions.delay); }; const hide = (delay: number) => { delayInvoke(() => { - setTarget(null); - setCurrentNode(null); + setOpen(false); }, delay); }; @@ -35,20 +45,60 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { [], ); - /** - * TODO: 参考 index.tsx 的逻辑,增加 Popup 支持: - * - >({}); + const parentContext = React.useContext(TriggerContext); - 用法 demo 在 docs/examples/two-buttons.tsx 中。 - */ + const triggerContextValue = React.useMemo( + () => ({ + registerSubPopup: (id, subPopupEle) => { + subPopupElements.current[id] = subPopupEle; + parentContext?.registerSubPopup(id, subPopupEle); + }, + }), + [parentContext], + ); // =========================== Render =========================== return ( {children} + {options && ( + + {}} + ready={true} + offsetX={0} + offsetY={0} + offsetR={0} + offsetB={0} + onAlign={() => {}} + onPrepare={() => Promise.resolve()} + arrowPos={{}} + align={ + options.popupAlign || { + points: ['tl', 'bl'], + offset: [0, 4], + } + } + zIndex={options.zIndex} + mask={options.mask} + arrow={options.arrow} + motion={options.popupMotion} + maskMotion={options.maskMotion} + getPopupContainer={options.getPopupContainer} + /> + + )} ); }; diff --git a/src/context.ts b/src/context.ts index 701786d6..c6787249 100644 --- a/src/context.ts +++ b/src/context.ts @@ -11,8 +11,27 @@ const TriggerContext = React.createContext(null); export default TriggerContext; // ==================== Unique ==================== +export interface UniqueShowOptions { + popup: TriggerProps['popup']; + target: HTMLElement; + delay: number; + prefixCls?: string; + popupClassName?: string; + popupStyle?: React.CSSProperties; + popupPlacement?: string; + builtinPlacements?: any; + popupAlign?: any; + zIndex?: number; + mask?: boolean; + maskClosable?: boolean; + popupMotion?: any; + maskMotion?: any; + arrow?: any; + getPopupContainer?: TriggerProps['getPopupContainer']; +} + export interface UniqueContextProps { - show: (popup: TriggerProps['popup'], target: HTMLElement, delay: number) => void; + show: (options: UniqueShowOptions) => void; hide: (delay: number) => void; } diff --git a/src/index.tsx b/src/index.tsx index 88d6b1f5..d315e067 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -327,7 +327,24 @@ export function generateTrigger( // If UniqueContext exists, pass delay to Provider instead of handling it internally if (uniqueContext) { if (nextOpen && targetEle) { - uniqueContext.show(popup, targetEle, delay); + uniqueContext.show({ + popup, + target: targetEle, + delay, + prefixCls, + popupClassName, + popupStyle, + popupPlacement, + builtinPlacements, + popupAlign, + zIndex, + mask, + maskClosable, + popupMotion, + maskMotion, + arrow, + getPopupContainer, + }); } else { uniqueContext.hide(delay); } From 016149392e7deeacab692e704a221579c12563c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 14:31:54 +0800 Subject: [PATCH 08/31] chore: move to show --- src/UniqueProvider/index.tsx | 52 +++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index da1183b0..6a04316f 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -7,6 +7,7 @@ import TriggerContext, { type UniqueShowOptions, } from '../context'; import useDelay from '../hooks/useDelay'; +import useAlign from '../hooks/useAlign'; import Popup from '../Popup'; export interface UniqueProviderProps { @@ -18,6 +19,7 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { const [target, setTarget] = React.useState(null); const [currentNode, setCurrentNode] = React.useState(null); const [options, setOptions] = React.useState(null); + const [popupEle, setPopupEle] = React.useState(null); // ========================== Register ========================== const delayInvoke = useDelay(); @@ -37,6 +39,31 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { }, delay); }; + // =========================== Align ============================ + const [ + ready, + offsetX, + offsetY, + offsetR, + offsetB, + arrowX, + arrowY, // scaleX - not used in UniqueProvider + // scaleY - not used in UniqueProvider + , + , + alignInfo, + onAlign, + ] = useAlign( + open, + popupEle, + target, + options?.popupPlacement, + options?.builtinPlacements || {}, + options?.popupAlign, + undefined, // onPopupAlign + false, // isMobile + ); + const contextValue = React.useMemo( () => ({ show, @@ -66,6 +93,7 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { {options && ( { keepDom={false} fresh={true} onVisibleChanged={() => {}} - ready={true} - offsetX={0} - offsetY={0} - offsetR={0} - offsetB={0} - onAlign={() => {}} + ready={ready} + offsetX={offsetX} + offsetY={offsetY} + offsetR={offsetR} + offsetB={offsetB} + onAlign={onAlign} onPrepare={() => Promise.resolve()} - arrowPos={{}} - align={ - options.popupAlign || { - points: ['tl', 'bl'], - offset: [0, 4], - } - } + arrowPos={{ + x: arrowX, + y: arrowY, + }} + align={alignInfo} zIndex={options.zIndex} mask={options.mask} arrow={options.arrow} From 12c41b6419a8cac47e94a8c2ce87f7ca2468aaf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 15:02:54 +0800 Subject: [PATCH 09/31] chore: update demo --- docs/examples/two-buttons.tsx | 20 ++++++++++---------- src/UniqueProvider/index.tsx | 20 ++++++++++++++++---- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index ebb40a43..d23de1b4 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -21,13 +21,9 @@ const builtinPlacements = { }, }; -function getPopupContainer(trigger) { - return trigger.parentNode; -} - const MovingPopupDemo = () => { const [useUniqueProvider, setUseUniqueProvider] = useState(true); - + const content = (
@@ -42,10 +38,10 @@ const MovingPopupDemo = () => {
{ > - + {
); - - return useUniqueProvider ? {content} : content; + + return useUniqueProvider ? ( + {content} + ) : ( + content + ); }; export default MovingPopupDemo; diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 6a04316f..a24e202a 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -9,6 +9,7 @@ import TriggerContext, { import useDelay from '../hooks/useDelay'; import useAlign from '../hooks/useAlign'; import Popup from '../Popup'; +import { useEvent } from '@rc-component/util'; export interface UniqueProviderProps { children: React.ReactNode; @@ -36,9 +37,19 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { const hide = (delay: number) => { delayInvoke(() => { setOpen(false); + // 不要立即清空 target, currentNode, options,等动画结束后再清空 }, delay); }; + // 动画完成后的回调 + const onVisibleChanged = useEvent((visible: boolean) => { + if (!visible) { + setTarget(null); + setCurrentNode(null); + setOptions(null); + } + }); + // =========================== Align ============================ const [ ready, @@ -48,9 +59,9 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { offsetB, arrowX, arrowY, // scaleX - not used in UniqueProvider - // scaleY - not used in UniqueProvider , , + // scaleY - not used in UniqueProvider alignInfo, onAlign, ] = useAlign( @@ -95,15 +106,16 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { {}} + autoDestroy={false} + onVisibleChanged={onVisibleChanged} ready={ready} offsetX={offsetX} offsetY={offsetY} From a5c946d460e06fc22530eddb1a01d996e49d8ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 15:49:38 +0800 Subject: [PATCH 10/31] chore: align it --- docs/examples/two-buttons.tsx | 6 ++- src/UniqueProvider/index.tsx | 55 ++++++++++++++++++---------- src/UniqueProvider/useTargetState.ts | 33 +++++++++++++++++ 3 files changed, 73 insertions(+), 21 deletions(-) create mode 100644 src/UniqueProvider/useTargetState.ts diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index d23de1b4..05f6a416 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -2,6 +2,8 @@ import Trigger, { UniqueProvider } from '@rc-component/trigger'; import React, { useState } from 'react'; import '../../assets/index.less'; +const LEAVE_DELAY = 0.1; + const builtinPlacements = { left: { points: ['cr', 'cl'], @@ -38,7 +40,7 @@ const MovingPopupDemo = () => {
{ { - const [open, setOpen] = React.useState(false); - const [target, setTarget] = React.useState(null); - const [currentNode, setCurrentNode] = React.useState(null); - const [options, setOptions] = React.useState(null); + const [trigger, open, options] = useTargetState(); + + // =========================== Popup ============================ const [popupEle, setPopupEle] = React.useState(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 delayInvoke = useDelay(); const show = (showOptions: UniqueShowOptions) => { delayInvoke(() => { - setOpen(true); - setCurrentNode(showOptions.popup); - setTarget(showOptions.target); - setOptions(showOptions); + trigger(showOptions); }, showOptions.delay); }; const hide = (delay: number) => { delayInvoke(() => { - setOpen(false); + trigger(false); // 不要立即清空 target, currentNode, options,等动画结束后再清空 }, delay); }; // 动画完成后的回调 const onVisibleChanged = useEvent((visible: boolean) => { - if (!visible) { - setTarget(null); - setCurrentNode(null); - setOptions(null); - } + // if (!visible) { + // setTarget(null); + // setCurrentNode(null); + // setOptions(null); + // } }); // =========================== Align ============================ @@ -67,7 +77,7 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { ] = useAlign( open, popupEle, - target, + options?.target, options?.popupPlacement, options?.builtinPlacements || {}, options?.popupAlign, @@ -83,6 +93,13 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { [], ); + // =========================== Motion =========================== + const onPrepare = useEvent(() => { + onAlign(); + + return Promise.resolve(); + }); + // ======================== Trigger Context ===================== const subPopupElements = React.useRef>({}); const parentContext = React.useContext(TriggerContext); @@ -104,13 +121,13 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { {options && ( { offsetR={offsetR} offsetB={offsetB} onAlign={onAlign} - onPrepare={() => Promise.resolve()} + onPrepare={onPrepare} arrowPos={{ x: arrowX, y: arrowY, diff --git a/src/UniqueProvider/useTargetState.ts b/src/UniqueProvider/useTargetState.ts new file mode 100644 index 00000000..b5e21dfb --- /dev/null +++ b/src/UniqueProvider/useTargetState.ts @@ -0,0 +1,33 @@ +import React from 'react'; +import type { TriggerProps } from '..'; +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. + */ +export default function useTargetState(): [ + trigger: (options: UniqueShowOptions | false) => void, + open: boolean, + /* Will always cache last which is not null */ + cacheOptions: UniqueShowOptions | null, +] { + const [options, setOptions] = React.useState(null); + const [open, setOpen] = React.useState(false); + + const trigger = useEvent((nextOptions: UniqueShowOptions | false) => { + if (nextOptions === false) { + setOpen(false); + } else { + setOpen(true); + setOptions(nextOptions); + } + }); + + return [trigger, open, options]; +} From a0319b963d1898ef7ea2f409e56d15fc0abf659d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 17:46:02 +0800 Subject: [PATCH 11/31] chore: motion pending --- src/UniqueProvider/index.tsx | 4 ++- src/UniqueProvider/useTargetState.ts | 44 +++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index a7bc8c95..48520877 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -18,7 +18,7 @@ export interface UniqueProviderProps { } const UniqueProvider = ({ children }: UniqueProviderProps) => { - const [trigger, open, options] = useTargetState(); + const [trigger, open, options, onTargetVisibleChanged] = useTargetState(); // =========================== Popup ============================ const [popupEle, setPopupEle] = React.useState(null); @@ -53,6 +53,8 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { // 动画完成后的回调 const onVisibleChanged = useEvent((visible: boolean) => { + // 调用 useTargetState 的回调来处理动画状态 + onTargetVisibleChanged(visible); // if (!visible) { // setTarget(null); // setCurrentNode(null); diff --git a/src/UniqueProvider/useTargetState.ts b/src/UniqueProvider/useTargetState.ts index b5e21dfb..b2d2358a 100644 --- a/src/UniqueProvider/useTargetState.ts +++ b/src/UniqueProvider/useTargetState.ts @@ -1,5 +1,4 @@ import React from 'react'; -import type { TriggerProps } from '..'; import { useEvent } from '@rc-component/util'; import type { UniqueShowOptions } from '../context'; @@ -10,24 +9,61 @@ import type { UniqueShowOptions } from '../context'; * 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) { + // 隐藏时清除待处理的选项 + pendingOptionsRef.current = null; setOpen(false); } else { - setOpen(true); - setOptions(nextOptions); + if (isAnimating && open) { + // 如果正在动画中(appear 或 enter),缓存新的 options + pendingOptionsRef.current = nextOptions; + } else { + // 没有动画或者是首次显示,直接应用 + setOpen(true); + setOptions(nextOptions); + pendingOptionsRef.current = null; + } } }); - return [trigger, open, options]; + const onVisibleChanged = useEvent((visible: boolean) => { + if (visible) { + // 动画进入完成,检查是否有待处理的选项 + setIsAnimating(false); + if (pendingOptionsRef.current) { + const pendingOptions = pendingOptionsRef.current; + pendingOptionsRef.current = null; + // 应用待处理的选项 + setOptions(pendingOptions); + } + } else { + // 动画离开完成 + setIsAnimating(false); + pendingOptionsRef.current = null; + } + }); + + // 当开始显示时标记为动画中 + React.useEffect(() => { + if (open && options) { + setIsAnimating(true); + } + }, [open, options]); + + return [trigger, open, options, onVisibleChanged]; } From 91b66bdcdab4d4e2b49a72dc3285f2fd3909ad0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 18:02:28 +0800 Subject: [PATCH 12/31] docs: update demo --- docs/examples/two-buttons.tsx | 58 +++++++++++++++++++++++----- src/UniqueProvider/index.tsx | 6 +-- src/UniqueProvider/useTargetState.ts | 27 ++++++------- 3 files changed, 63 insertions(+), 28 deletions(-) diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index 05f6a416..6f62f000 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -25,25 +25,17 @@ const builtinPlacements = { const MovingPopupDemo = () => { const [useUniqueProvider, setUseUniqueProvider] = useState(true); + const [triggerControl, setTriggerControl] = useState('none'); // 'button1', 'button2', 'none' const content = (
-
- -
{ action={['hover']} popupPlacement="top" builtinPlacements={builtinPlacements} + popupVisible={triggerControl === 'button2' || undefined} popupMotion={{ motionName: 'rc-trigger-popup-zoom', }} @@ -77,6 +70,51 @@ const MovingPopupDemo = () => {
+ +
+ +
+ +
+
Trigger 控制:
+ + + +
); diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 48520877..0f84f0e1 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -47,13 +47,13 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { const hide = (delay: number) => { delayInvoke(() => { trigger(false); - // 不要立即清空 target, currentNode, options,等动画结束后再清空 + // Don't clear target, currentNode, options immediately, wait until animation completes }, delay); }; - // 动画完成后的回调 + // Callback after animation completes const onVisibleChanged = useEvent((visible: boolean) => { - // 调用 useTargetState 的回调来处理动画状态 + // Call useTargetState callback to handle animation state onTargetVisibleChanged(visible); // if (!visible) { // setTarget(null); diff --git a/src/UniqueProvider/useTargetState.ts b/src/UniqueProvider/useTargetState.ts index b2d2358a..77d337a6 100644 --- a/src/UniqueProvider/useTargetState.ts +++ b/src/UniqueProvider/useTargetState.ts @@ -25,45 +25,42 @@ export default function useTargetState(): [ const trigger = useEvent((nextOptions: UniqueShowOptions | false) => { if (nextOptions === false) { - // 隐藏时清除待处理的选项 + // Clear pending options when hiding pendingOptionsRef.current = null; setOpen(false); } else { if (isAnimating && open) { - // 如果正在动画中(appear 或 enter),缓存新的 options + // If animating (appear or enter), cache new options pendingOptionsRef.current = nextOptions; } else { - // 没有动画或者是首次显示,直接应用 setOpen(true); + // Use functional update to ensure re-render is always triggered 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) { - const pendingOptions = pendingOptionsRef.current; + // Apply pending options - Use functional update to ensure re-render is triggered + setOptions(pendingOptionsRef.current); pendingOptionsRef.current = null; - // 应用待处理的选项 - setOptions(pendingOptions); } } else { - // 动画离开完成 + // Animation leave completed setIsAnimating(false); pendingOptionsRef.current = null; } }); - // 当开始显示时标记为动画中 - React.useEffect(() => { - if (open && options) { - setIsAnimating(true); - } - }, [open, options]); - return [trigger, open, options, onVisibleChanged]; } From 5273538a4dd005362367305d4147fec0dd78b52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 12 Sep 2025 18:14:42 +0800 Subject: [PATCH 13/31] chore: demo update --- docs/examples/two-buttons.tsx | 14 ++++++- src/index.tsx | 69 ++++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 24 deletions(-) diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index 6f62f000..0ec42c05 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -27,6 +27,16 @@ 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 = (
@@ -35,7 +45,7 @@ const MovingPopupDemo = () => { action={['hover']} popupPlacement="top" builtinPlacements={builtinPlacements} - popupVisible={triggerControl === 'button1' || undefined} + popupVisible={getVisible('button1')} popupMotion={{ motionName: 'rc-trigger-popup-zoom', }} @@ -55,7 +65,7 @@ const MovingPopupDemo = () => { action={['hover']} popupPlacement="top" builtinPlacements={builtinPlacements} - popupVisible={triggerControl === 'button2' || undefined} + popupVisible={getVisible('button2')} popupMotion={{ motionName: 'rc-trigger-popup-zoom', }} diff --git a/src/index.tsx b/src/index.tsx index d315e067..8b328222 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -193,6 +193,7 @@ export function generateTrigger( } = props; const mergedAutoDestroy = autoDestroy || false; + const openUncontrolled = popupVisible === undefined; // =========================== Mobile =========================== const isMobile = !!mobile; @@ -289,7 +290,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); } }); @@ -298,6 +299,38 @@ 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, + getPopupContainer, + })); + + // Handle controlled state changes for UniqueProvider + // Only sync to UniqueProvider when it's controlled mode + useLayoutEffect(() => { + if (uniqueContext && targetEle && !openUncontrolled) { + if (mergedOpen) { + uniqueContext.show(getUniqueOptions(0)); + } else { + uniqueContext.hide(0); + } + } + }, [mergedOpen]); + const openRef = React.useRef(mergedOpen); openRef.current = mergedOpen; @@ -324,27 +357,19 @@ export function generateTrigger( const delayInvoke = useDelay(); const triggerOpen = (nextOpen: boolean, delay = 0) => { - // If UniqueContext exists, pass delay to Provider instead of handling it internally - if (uniqueContext) { - if (nextOpen && targetEle) { - uniqueContext.show({ - popup, - target: targetEle, - delay, - prefixCls, - popupClassName, - popupStyle, - popupPlacement, - builtinPlacements, - popupAlign, - zIndex, - mask, - maskClosable, - popupMotion, - maskMotion, - arrow, - getPopupContainer, - }); + // If it's controlled mode, always use internal trigger logic + // UniqueProvider will be synced through useLayoutEffect + if (popupVisible !== undefined) { + delayInvoke(() => { + internalTriggerOpen(nextOpen); + }, delay); + return; + } + + // If UniqueContext exists and not controlled, pass delay to Provider instead of handling it internally + if (uniqueContext && openUncontrolled) { + if (nextOpen) { + uniqueContext.show(getUniqueOptions(delay)); } else { uniqueContext.hide(delay); } From 8426ae28123909e7d69bc9c31ae4a95c8887906a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 10:10:35 +0800 Subject: [PATCH 14/31] chore: delay of context --- src/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 8b328222..2cf539b9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -324,7 +324,9 @@ export function generateTrigger( useLayoutEffect(() => { if (uniqueContext && targetEle && !openUncontrolled) { if (mergedOpen) { - uniqueContext.show(getUniqueOptions(0)); + Promise.resolve().then(() => { + uniqueContext.show(getUniqueOptions(0)); + }); } else { uniqueContext.hide(0); } From a7ff7d2ce3ca04262b7bfea2c05115eb3f8738ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 10:45:01 +0800 Subject: [PATCH 15/31] chore: tmp of it --- src/Popup/index.tsx | 9 +++++++-- src/UniqueProvider/FloatBg.tsx | 20 ++++++++++++++++++++ src/UniqueProvider/index.tsx | 6 ++++-- src/hooks/useOffsetStyle.ts | 3 +++ 4 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 src/UniqueProvider/FloatBg.tsx create mode 100644 src/hooks/useOffsetStyle.ts diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 37e43ce5..5c69d7c4 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -58,6 +58,8 @@ export interface PopupProps { autoDestroy?: boolean; portal: React.ComponentType; + children?: React.ReactElement; + // Align ready: boolean; offsetX: number; @@ -114,6 +116,7 @@ const Popup = React.forwardRef((props, ref) => { getPopupContainer, autoDestroy, portal: Portal, + children, zIndex, @@ -135,7 +138,7 @@ const Popup = React.forwardRef((props, ref) => { 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; @@ -177,6 +180,7 @@ const Popup = React.forwardRef((props, ref) => { return null; } + // TODO: Move offsetStyle logic to useOffsetStyle.ts hooks // >>>>> Offset const AUTO = 'auto' as const; @@ -305,7 +309,7 @@ const Popup = React.forwardRef((props, ref) => { /> )} - {childNode} + {popupContent}
); @@ -314,6 +318,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..b268ed9f --- /dev/null +++ b/src/UniqueProvider/FloatBg.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +export interface FloatBgProps { + prefixCls: string; // ${prefixCls}-float-bg + popupEle: HTMLElement; +} + +const FloatBg = (props: FloatBgProps) => { + const { prefixCls, popupEle } = props; + + // Apply className as requested in TODO + const className = `${prefixCls}-float-bg`; + + // Remove console.log as it's for debugging only + // console.log('>>>', popupEle); + + return
; +}; + +export default FloatBg; diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 0f84f0e1..f2f5904a 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -12,6 +12,7 @@ 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'; export interface UniqueProviderProps { children: React.ReactNode; @@ -32,7 +33,6 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { if (isDOM(node) && popupEle !== node) { setPopupEle(node); } - }); // ========================== Register ========================== @@ -153,7 +153,9 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { motion={options.popupMotion} maskMotion={options.maskMotion} getPopupContainer={options.getPopupContainer} - /> + > + + )} diff --git a/src/hooks/useOffsetStyle.ts b/src/hooks/useOffsetStyle.ts new file mode 100644 index 00000000..14ee7e64 --- /dev/null +++ b/src/hooks/useOffsetStyle.ts @@ -0,0 +1,3 @@ +import type * as React from 'react'; + +export default function useAlignStyle() {} From e1c6fd15926b35002d54e8a29cd8d834874ebd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 10:57:14 +0800 Subject: [PATCH 16/31] chore: get a hooks --- src/Popup/index.tsx | 51 ++++++++++-------------------------- src/hooks/useOffsetStyle.ts | 52 ++++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index 5c69d7c4..c16be519 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -10,6 +10,7 @@ import type { AlignType, ArrowPos, ArrowTypeOuter } from '../interface'; import Arrow from './Arrow'; import Mask from './Mask'; import PopupContent from './PopupContent'; +import useOffsetStyle from '../hooks/useOffsetStyle'; export interface MobileConfig { mask?: boolean; @@ -175,49 +176,23 @@ const Popup = React.forwardRef((props, ref) => { } }, [show, getPopupContainerNeedParams, target]); + // ========================= Styles ========================= + const offsetStyle = useOffsetStyle( + isMobile, + ready, + open, + align, + offsetR, + offsetB, + offsetX, + offsetY, + ); + // ========================= Render ========================= if (!show) { return null; } - // TODO: Move offsetStyle logic to useOffsetStyle.ts hooks - // >>>>> 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) { diff --git a/src/hooks/useOffsetStyle.ts b/src/hooks/useOffsetStyle.ts index 14ee7e64..9bb11a74 100644 --- a/src/hooks/useOffsetStyle.ts +++ b/src/hooks/useOffsetStyle.ts @@ -1,3 +1,53 @@ import type * as React from 'react'; +import type { AlignType } from '../interface'; -export default function useAlignStyle() {} +export default function useOffsetStyle( + isMobile: boolean, + ready: boolean, + open: boolean, + align: AlignType, + offsetR: number, + offsetB: number, + offsetX: number, + offsetY: number, +) { + // TODO: Move offsetStyle logic to useOffsetStyle.ts hooks + // >>>>> 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; +} From 2579ec5b62986e6d265d0cccbd42c9b9da7c175e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 11:43:09 +0800 Subject: [PATCH 17/31] chore: floating --- assets/index.less | 17 +++++++++++++ src/Popup/index.tsx | 19 +++++++++++++-- src/UniqueProvider/FloatBg.tsx | 44 ++++++++++++++++++++++++++++++++-- src/UniqueProvider/index.tsx | 34 +++++++++++++++++++++++--- 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/assets/index.less b/assets/index.less index e13a1256..11acc185 100644 --- a/assets/index.less +++ b/assets/index.less @@ -73,6 +73,23 @@ opacity: 0; } } + + // =============== Float BG =============== + &-float-bg { + position: absolute; + z-index: 0; + box-sizing: border-box; + border: 1px solid red; + background: green; + transition: all 0.3s; + } + + // Debug + &-unique-controlled { + border-color: rgba(0, 0, 0, 0.01) !important; + background: transparent !important; + z-index: 1; + } } @import './index/Mask'; diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index c16be519..a9a3242b 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'; @@ -11,6 +13,7 @@ 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; @@ -75,6 +78,9 @@ export interface PopupProps { targetWidth?: number; targetHeight?: number; + // Resize + onResize?: ResizeObserverProps['onResize']; + // Mobile mobile?: MobileConfig; } @@ -134,6 +140,9 @@ const Popup = React.forwardRef((props, ref) => { onAlign, onPrepare, + // Resize + onResize, + stretch, targetWidth, targetHeight, @@ -176,6 +185,12 @@ const Popup = React.forwardRef((props, ref) => { } }, [show, getPopupContainerNeedParams, target]); + // ========================= Resize ========================= + const onInternalResize: ResizeObserverProps['onResize'] = useEvent((size) => { + onResize?.(size); + onAlign(); + }); + // ========================= Styles ========================= const offsetStyle = useOffsetStyle( isMobile, @@ -226,7 +241,7 @@ const Popup = React.forwardRef((props, ref) => { motion={mergedMaskMotion} mobile={isMobile} /> - + {(resizeObserverRef) => { return ( { - const { prefixCls, popupEle } = props; + const { + prefixCls, + popupEle, + isMobile, + ready, + open, + align, + offsetR, + offsetB, + offsetX, + offsetY, + popupSize, + } = props; // Apply className as requested in TODO const className = `${prefixCls}-float-bg`; + 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; + } + // Remove console.log as it's for debugging only // console.log('>>>', popupEle); - return
; + return
; }; export default FloatBg; diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index f2f5904a..767957b1 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -13,6 +13,7 @@ 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'; export interface UniqueProviderProps { children: React.ReactNode; @@ -23,6 +24,10 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { // =========================== 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); @@ -117,6 +122,8 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { ); // =========================== Render =========================== + const prefixCls = options?.prefixCls; + return ( {children} @@ -125,9 +132,12 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { { offsetB={offsetB} onAlign={onAlign} onPrepare={onPrepare} + onResize={(size) => + setPopupSize({ + width: size.offsetWidth, + height: size.offsetHeight, + }) + } arrowPos={{ x: arrowX, y: arrowY, @@ -154,7 +170,19 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { maskMotion={options.maskMotion} getPopupContainer={options.getPopupContainer} > - + )} From 5a53dd60e89b33f925589e2c1d577d83c933f417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 15:50:50 +0800 Subject: [PATCH 18/31] chore: motion of bg --- assets/index.less | 9 +++++- src/UniqueProvider/FloatBg.tsx | 53 +++++++++++++++++++++++++++++++--- src/UniqueProvider/index.tsx | 1 + 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/assets/index.less b/assets/index.less index 11acc185..883cb34c 100644 --- a/assets/index.less +++ b/assets/index.less @@ -81,7 +81,14 @@ box-sizing: border-box; border: 1px solid red; background: green; - transition: all 0.3s; + + &-hidden { + display: none; + } + + &-visible { + transition: all 0.3s; + } } // Debug diff --git a/src/UniqueProvider/FloatBg.tsx b/src/UniqueProvider/FloatBg.tsx index 18fed366..51c95393 100644 --- a/src/UniqueProvider/FloatBg.tsx +++ b/src/UniqueProvider/FloatBg.tsx @@ -1,5 +1,8 @@ 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'; export interface FloatBgProps { prefixCls: string; // ${prefixCls}-float-bg @@ -13,6 +16,7 @@ export interface FloatBgProps { offsetX: number; offsetY: number; popupSize?: { width: number; height: number }; + motion?: CSSMotionProps; } const FloatBg = (props: FloatBgProps) => { @@ -28,11 +32,21 @@ const FloatBg = (props: FloatBgProps) => { offsetX, offsetY, popupSize, + motion, } = props; - // Apply className as requested in TODO - const className = `${prefixCls}-float-bg`; + const floatBgCls = `${prefixCls}-float-bg`; + // ========================= Ready ========================== + // const [delayReady, setDelayReady] = React.useState(false); + + // React.useEffect(() => { + // setDelayReady(ready); + // }, [ready]); + + const [motionVisible, setMotionVisible] = React.useState(false); + + // ========================= Styles ========================= const offsetStyle = useOffsetStyle( isMobile, ready, @@ -52,9 +66,40 @@ const FloatBg = (props: FloatBgProps) => { } // Remove console.log as it's for debugging only - // console.log('>>>', popupEle); + // console.log('>>>', ready, open, offsetStyle); - return
; + // ========================= 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/index.tsx b/src/UniqueProvider/index.tsx index 767957b1..be5bdad6 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -182,6 +182,7 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { offsetX={offsetX} offsetY={offsetY} popupSize={popupSize} + motion={options.popupMotion} /> From 98cfa025c74a3fce61429c41097dd2477ed2229e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 16:43:08 +0800 Subject: [PATCH 19/31] chore: flying --- assets/index.less | 26 ++++++++++++++++++- docs/examples/two-buttons.tsx | 2 +- src/Popup/index.tsx | 10 +++++--- src/UniqueProvider/MotionContent.tsx | 38 ++++++++++++++++++++++++++++ src/UniqueProvider/index.tsx | 21 +++++++++------ src/context.ts | 1 + src/index.tsx | 1 + 7 files changed, 85 insertions(+), 14 deletions(-) create mode 100644 src/UniqueProvider/MotionContent.tsx diff --git a/assets/index.less b/assets/index.less index 883cb34c..d7ffee51 100644 --- a/assets/index.less +++ b/assets/index.less @@ -87,7 +87,7 @@ } &-visible { - transition: all 0.3s; + transition: all 0.1s; } } @@ -97,6 +97,30 @@ 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/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index 0ec42c05..7a9dd394 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -2,7 +2,7 @@ import Trigger, { UniqueProvider } from '@rc-component/trigger'; import React, { useState } from 'react'; import '../../assets/index.less'; -const LEAVE_DELAY = 0.1; +const LEAVE_DELAY = 0.2; const builtinPlacements = { left: { diff --git a/src/Popup/index.tsx b/src/Popup/index.tsx index a9a3242b..8fdcdc93 100644 --- a/src/Popup/index.tsx +++ b/src/Popup/index.tsx @@ -186,10 +186,12 @@ const Popup = React.forwardRef((props, ref) => { }, [show, getPopupContainerNeedParams, target]); // ========================= Resize ========================= - const onInternalResize: ResizeObserverProps['onResize'] = useEvent((size) => { - onResize?.(size); - onAlign(); - }); + const onInternalResize: ResizeObserverProps['onResize'] = useEvent( + (size, ele) => { + onResize?.(size, ele); + onAlign(); + }, + ); // ========================= Styles ========================= const offsetStyle = useOffsetStyle( 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 index be5bdad6..9eed5c80 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -14,6 +14,7 @@ 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; @@ -41,13 +42,18 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { }); // ========================== Register ========================== + const [popupId, setPopupId] = React.useState(0); + const delayInvoke = useDelay(); - const show = (showOptions: UniqueShowOptions) => { + const show = useEvent((showOptions: UniqueShowOptions) => { delayInvoke(() => { + if (showOptions.id !== options?.id) { + setPopupId((i) => i + 1); + } trigger(showOptions); }, showOptions.delay); - }; + }); const hide = (delay: number) => { delayInvoke(() => { @@ -60,11 +66,6 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { const onVisibleChanged = useEvent((visible: boolean) => { // Call useTargetState callback to handle animation state onTargetVisibleChanged(visible); - // if (!visible) { - // setTarget(null); - // setCurrentNode(null); - // setOptions(null); - // } }); // =========================== Align ============================ @@ -133,7 +134,11 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { ref={setPopupRef} portal={Portal} prefixCls={prefixCls} - popup={options.popup} + popup={ + + {options.popup} + + } className={classNames( options.popupClassName, `${prefixCls}-unique-controlled`, diff --git a/src/context.ts b/src/context.ts index c6787249..196cb5e0 100644 --- a/src/context.ts +++ b/src/context.ts @@ -12,6 +12,7 @@ export default TriggerContext; // ==================== Unique ==================== export interface UniqueShowOptions { + id: string; popup: TriggerProps['popup']; target: HTMLElement; delay: number; diff --git a/src/index.tsx b/src/index.tsx index 2cf539b9..d87cdfad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -317,6 +317,7 @@ export function generateTrigger( maskMotion, arrow, getPopupContainer, + id, })); // Handle controlled state changes for UniqueProvider From 4db2dba3a0b10c6c5ae99bebfec05c2f7fb1f2c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 17:06:38 +0800 Subject: [PATCH 20/31] chore: unique it --- docs/examples/two-buttons.tsx | 2 ++ src/index.tsx | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/examples/two-buttons.tsx b/docs/examples/two-buttons.tsx index 7a9dd394..8b18c2ee 100644 --- a/docs/examples/two-buttons.tsx +++ b/docs/examples/two-buttons.tsx @@ -56,6 +56,7 @@ const MovingPopupDemo = () => { background: 'white', boxSizing: 'border-box', }} + unique > @@ -76,6 +77,7 @@ const MovingPopupDemo = () => { background: 'white', boxSizing: 'border-box', }} + unique > diff --git a/src/index.tsx b/src/index.tsx index d87cdfad..88ee1ecd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -111,6 +111,11 @@ export interface TriggerProps { */ fresh?: boolean; + /** + * Config with UniqueProvider to shared the floating popup. + */ + unique?: boolean; + // ==================== Arrow ==================== arrow?: boolean | ArrowTypeOuter; @@ -173,6 +178,7 @@ export function generateTrigger( stretch, getPopupClassNameFromAlign, fresh, + unique, alignPoint, @@ -323,7 +329,7 @@ export function generateTrigger( // Handle controlled state changes for UniqueProvider // Only sync to UniqueProvider when it's controlled mode useLayoutEffect(() => { - if (uniqueContext && targetEle && !openUncontrolled) { + if (uniqueContext && unique && targetEle && !openUncontrolled) { if (mergedOpen) { Promise.resolve().then(() => { uniqueContext.show(getUniqueOptions(0)); @@ -370,7 +376,7 @@ export function generateTrigger( } // If UniqueContext exists and not controlled, pass delay to Provider instead of handling it internally - if (uniqueContext && openUncontrolled) { + if (uniqueContext && unique && openUncontrolled) { if (nextOpen) { uniqueContext.show(getUniqueOptions(delay)); } else { @@ -769,7 +775,7 @@ export function generateTrigger( > {triggerNode} - {rendedRef.current && !uniqueContext && ( + {rendedRef.current && (!uniqueContext || !unique) && ( Date: Mon, 15 Sep 2025 17:32:49 +0800 Subject: [PATCH 21/31] test: init --- tests/unique.test.tsx | 57 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 tests/unique.test.tsx diff --git a/tests/unique.test.tsx b/tests/unique.test.tsx new file mode 100644 index 00000000..7902f4f5 --- /dev/null +++ b/tests/unique.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import Trigger, { UniqueProvider } from '../src'; +import { awaitFakeTimer } from './util'; + +describe('Trigger.Unique', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + jest.useRealTimers(); + }); + + it('moving will not hide the popup', async () => { + const { container } = render( + + tooltip1} + unique + > +
hover1
+
+ tooltip2} + unique + > +
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(); + + // Move from first to second trigger - popup should not hide, but content should change + fireEvent.mouseLeave(container.querySelector('.target1')); + fireEvent.mouseEnter(container.querySelector('.target2')); + await awaitFakeTimer(); + + // Popup should still be visible with new content + expect(document.querySelector('.x-content').textContent).toBe('tooltip2'); + expect(document.querySelector('.rc-trigger-popup')).toBeTruthy(); + + // There should only be one popup element + expect(document.querySelectorAll('.rc-trigger-popup').length).toBe(1); + }); +}); From 3007967b5a233f7f69e3e668ac976422701a0366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 17:46:07 +0800 Subject: [PATCH 22/31] test: init --- tests/unique.test.tsx | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/unique.test.tsx b/tests/unique.test.tsx index 7902f4f5..2a1a9b36 100644 --- a/tests/unique.test.tsx +++ b/tests/unique.test.tsx @@ -20,6 +20,7 @@ describe('Trigger.Unique', () => { action={['hover']} popup={tooltip1} unique + mouseLeaveDelay={0.1} >
hover1
@@ -27,6 +28,7 @@ describe('Trigger.Unique', () => { action={['hover']} popup={tooltip2} unique + mouseLeaveDelay={0.1} >
hover2
@@ -42,16 +44,35 @@ describe('Trigger.Unique', () => { 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 + // 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, + ); }); }); From b2341f11b9bfb3bee15aa75807b52537b222bffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 18:07:21 +0800 Subject: [PATCH 23/31] test: add test case --- tests/unique.test.tsx | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/unique.test.tsx b/tests/unique.test.tsx index 2a1a9b36..e5e5e300 100644 --- a/tests/unique.test.tsx +++ b/tests/unique.test.tsx @@ -3,9 +3,34 @@ import React from 'react'; import Trigger, { UniqueProvider } from '../src'; import { awaitFakeTimer } from './util'; +// Mock FloatBg to check if open props changed +global.openChangeLog = []; + +jest.mock('../src/UniqueProvider/FloatBg', () => { + 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(() => { @@ -74,5 +99,8 @@ describe('Trigger.Unique', () => { 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); }); }); From 48e70fdb0985abf9f2670fc061b5a52bd7e85f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 15 Sep 2025 18:15:28 +0800 Subject: [PATCH 24/31] chore: lint --- src/UniqueProvider/FloatBg.tsx | 2 -- src/UniqueProvider/index.tsx | 1 - 2 files changed, 3 deletions(-) diff --git a/src/UniqueProvider/FloatBg.tsx b/src/UniqueProvider/FloatBg.tsx index 51c95393..9392ef28 100644 --- a/src/UniqueProvider/FloatBg.tsx +++ b/src/UniqueProvider/FloatBg.tsx @@ -6,7 +6,6 @@ import type { CSSMotionProps } from '@rc-component/motion'; export interface FloatBgProps { prefixCls: string; // ${prefixCls}-float-bg - popupEle: HTMLElement; isMobile: boolean; ready: boolean; open: boolean; @@ -22,7 +21,6 @@ export interface FloatBgProps { const FloatBg = (props: FloatBgProps) => { const { prefixCls, - popupEle, isMobile, ready, open, diff --git a/src/UniqueProvider/index.tsx b/src/UniqueProvider/index.tsx index 9eed5c80..6fc9a0c9 100644 --- a/src/UniqueProvider/index.tsx +++ b/src/UniqueProvider/index.tsx @@ -177,7 +177,6 @@ const UniqueProvider = ({ children }: UniqueProviderProps) => { > Date: Tue, 16 Sep 2025 09:45:51 +0800 Subject: [PATCH 25/31] Update src/context.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/context.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/context.ts b/src/context.ts index 196cb5e0..6017bcea 100644 --- a/src/context.ts +++ b/src/context.ts @@ -20,14 +20,14 @@ export interface UniqueShowOptions { popupClassName?: string; popupStyle?: React.CSSProperties; popupPlacement?: string; - builtinPlacements?: any; - popupAlign?: any; + builtinPlacements?: BuildInPlacements; + popupAlign?: AlignType; zIndex?: number; mask?: boolean; maskClosable?: boolean; - popupMotion?: any; - maskMotion?: any; - arrow?: any; + popupMotion?: CSSMotionProps; + maskMotion?: CSSMotionProps; + arrow?: boolean | ArrowTypeOuter; getPopupContainer?: TriggerProps['getPopupContainer']; } From 2d376b1c172470e7d3a9e1035390b1e788057f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Tue, 16 Sep 2025 09:55:44 +0800 Subject: [PATCH 26/31] Update src/UniqueProvider/useTargetState.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/UniqueProvider/useTargetState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UniqueProvider/useTargetState.ts b/src/UniqueProvider/useTargetState.ts index 77d337a6..03d2f5a5 100644 --- a/src/UniqueProvider/useTargetState.ts +++ b/src/UniqueProvider/useTargetState.ts @@ -51,7 +51,7 @@ export default function useTargetState(): [ // Animation enter completed, check if there are pending options setIsAnimating(false); if (pendingOptionsRef.current) { - // Apply pending options - Use functional update to ensure re-render is triggered + // Apply pending options setOptions(pendingOptionsRef.current); pendingOptionsRef.current = null; } From ea9e47b8aa3614c14bedfef5e824d51d05588c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 16 Sep 2025 09:56:55 +0800 Subject: [PATCH 27/31] chore: of it --- src/UniqueProvider/FloatBg.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/UniqueProvider/FloatBg.tsx b/src/UniqueProvider/FloatBg.tsx index 9392ef28..c7605b8b 100644 --- a/src/UniqueProvider/FloatBg.tsx +++ b/src/UniqueProvider/FloatBg.tsx @@ -3,13 +3,14 @@ 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: any; + align: AlignType; offsetR: number; offsetB: number; offsetX: number; From 79566ee5bec649d2abccfaa4496f8e8738ccaf62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E7=88=B1=E5=90=83=E7=99=BD=E8=90=9D?= =?UTF-8?q?=E5=8D=9C?= Date: Tue, 16 Sep 2025 10:08:36 +0800 Subject: [PATCH 28/31] Update src/UniqueProvider/useTargetState.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- src/UniqueProvider/useTargetState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UniqueProvider/useTargetState.ts b/src/UniqueProvider/useTargetState.ts index 03d2f5a5..04c39c9a 100644 --- a/src/UniqueProvider/useTargetState.ts +++ b/src/UniqueProvider/useTargetState.ts @@ -34,7 +34,7 @@ export default function useTargetState(): [ pendingOptionsRef.current = nextOptions; } else { setOpen(true); - // Use functional update to ensure re-render is always triggered + // Set new options setOptions(nextOptions); pendingOptionsRef.current = null; From 5e9bcf069884b5f85443f5760a87dd0d18b745e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 16 Sep 2025 10:09:22 +0800 Subject: [PATCH 29/31] chore: of it --- src/UniqueProvider/FloatBg.tsx | 10 ---------- src/hooks/useOffsetStyle.ts | 1 - 2 files changed, 11 deletions(-) diff --git a/src/UniqueProvider/FloatBg.tsx b/src/UniqueProvider/FloatBg.tsx index c7605b8b..1a7b9e05 100644 --- a/src/UniqueProvider/FloatBg.tsx +++ b/src/UniqueProvider/FloatBg.tsx @@ -36,13 +36,6 @@ const FloatBg = (props: FloatBgProps) => { const floatBgCls = `${prefixCls}-float-bg`; - // ========================= Ready ========================== - // const [delayReady, setDelayReady] = React.useState(false); - - // React.useEffect(() => { - // setDelayReady(ready); - // }, [ready]); - const [motionVisible, setMotionVisible] = React.useState(false); // ========================= Styles ========================= @@ -64,9 +57,6 @@ const FloatBg = (props: FloatBgProps) => { sizeStyle.height = popupSize.height; } - // Remove console.log as it's for debugging only - // console.log('>>>', ready, open, offsetStyle); - // ========================= Render ========================= return ( >>>> Offset const AUTO = 'auto' as const; From 948c85958d290cfc8cf6542d857e3b10b36192b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 16 Sep 2025 10:14:49 +0800 Subject: [PATCH 30/31] chore: fix ts --- src/context.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/context.ts b/src/context.ts index 6017bcea..8635ce37 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,5 +1,7 @@ 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 { From 947972ec5f6f172835ae2a3ef50ecdfc04646d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 16 Sep 2025 10:24:13 +0800 Subject: [PATCH 31/31] chore: fix ts --- src/context.ts | 2 +- src/index.tsx | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/context.ts b/src/context.ts index 8635ce37..98b77e3a 100644 --- a/src/context.ts +++ b/src/context.ts @@ -29,7 +29,7 @@ export interface UniqueShowOptions { maskClosable?: boolean; popupMotion?: CSSMotionProps; maskMotion?: CSSMotionProps; - arrow?: boolean | ArrowTypeOuter; + arrow?: ArrowTypeOuter; getPopupContainer?: TriggerProps['getPopupContainer']; } diff --git a/src/index.tsx b/src/index.tsx index 88ee1ecd..b7063d63 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -286,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, @@ -321,7 +329,7 @@ export function generateTrigger( maskClosable, popupMotion, maskMotion, - arrow, + arrow: innerArrow, getPopupContainer, id, })); @@ -752,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,