Skip to content

Commit ca3d678

Browse files
author
罗忠泽
committed
fix: Continue timing when mouse moves away
1 parent 477e95f commit ca3d678

2 files changed

Lines changed: 211 additions & 5 deletions

File tree

src/NoticeList.tsx

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ const NoticeList: FC<NoticeListProps> = (props) => {
4646
const { classNames: ctxCls } = useContext(NotificationContext);
4747

4848
const dictRef = useRef<Record<string, HTMLDivElement>>({});
49-
const [latestNotice, setLatestNotice] = useState<HTMLDivElement>(null);
49+
const mousePositionRef = useRef<{ x: number; y: number } | null>(null);
50+
const [latestNotice, setLatestNotice] = useState<HTMLDivElement | null>(null);
5051
const [hoverKeys, setHoverKeys] = useState<string[]>([]);
5152

5253
const keys = configList.map((config) => ({
@@ -60,15 +61,63 @@ const NoticeList: FC<NoticeListProps> = (props) => {
6061

6162
const placementMotion = typeof motion === 'function' ? motion(placement) : motion;
6263

64+
// Track mouse position globally when in stack mode
65+
useEffect(() => {
66+
if (!stack) return;
67+
68+
const handleMouseMove = (e: MouseEvent) => {
69+
mousePositionRef.current = { x: e.clientX, y: e.clientY };
70+
};
71+
72+
document.addEventListener('mousemove', handleMouseMove, { passive: true });
73+
return () => document.removeEventListener('mousemove', handleMouseMove);
74+
}, [stack]);
75+
6376
// Clean hover key
6477
useEffect(() => {
6578
if (stack && hoverKeys.length > 1) {
66-
setHoverKeys((prev) =>
67-
prev.filter((key) => keys.some(({ key: dataKey }) => key === dataKey)),
68-
);
79+
// Only update if there's a change to avoid unnecessary re-renders
80+
setHoverKeys((prev) => {
81+
const filtered = prev.filter((key) => keys.some(({ key: dataKey }) => key === dataKey));
82+
return filtered.length === prev.length ? prev : filtered;
83+
});
6984
}
7085
}, [hoverKeys, keys, stack]);
7186

87+
// Check mouse position when keys change (notification list updates)
88+
useEffect(() => {
89+
if (!stack || !mousePositionRef.current) return;
90+
91+
// Use requestAnimationFrame to wait for DOM updates
92+
const rafId = requestAnimationFrame(() => {
93+
const mousePos = mousePositionRef.current;
94+
if (!mousePos) return;
95+
96+
const newHoverKeys: string[] = [];
97+
keys.forEach(({ key: strKey }) => {
98+
const element = dictRef.current[strKey];
99+
if (element) {
100+
const rect = element.getBoundingClientRect();
101+
if (
102+
mousePos.x >= rect.left &&
103+
mousePos.x <= rect.right &&
104+
mousePos.y >= rect.top &&
105+
mousePos.y <= rect.bottom
106+
) {
107+
newHoverKeys.push(strKey);
108+
}
109+
}
110+
});
111+
112+
// Only update if there's a change to avoid unnecessary re-renders
113+
if (newHoverKeys.length > 0 || hoverKeys.length > 0) {
114+
setHoverKeys(newHoverKeys);
115+
}
116+
});
117+
118+
return () => cancelAnimationFrame(rafId);
119+
}, [keys, stack]);
120+
72121
// Force update latest notice
73122
useEffect(() => {
74123
if (stack && dictRef.current[keys[keys.length - 1]?.key]) {

tests/stack.test.tsx

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useNotification } from '../src';
2-
import { fireEvent, render } from '@testing-library/react';
2+
import { act, fireEvent, render } from '@testing-library/react';
33
import React from 'react';
44

55
require('../assets/index.less');
@@ -87,3 +87,160 @@ describe('stack', () => {
8787
expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy();
8888
});
8989
});
90+
91+
describe('hover state after closing notice in stack', () => {
92+
beforeEach(() => {
93+
vi.useFakeTimers();
94+
});
95+
96+
afterEach(() => {
97+
vi.useRealTimers();
98+
});
99+
100+
it('should clear hover state and resume timers when closing a hovered notice', () => {
101+
const onClose = vi.fn();
102+
103+
const Demo = () => {
104+
const [api, holder] = useNotification({
105+
stack: { threshold: 3 },
106+
});
107+
return (
108+
<>
109+
<button
110+
type="button"
111+
onClick={() => {
112+
api.open({
113+
content: <div className="context-content">Test</div>,
114+
duration: 1,
115+
closable: true,
116+
onClose,
117+
});
118+
}}
119+
/>
120+
{holder}
121+
</>
122+
);
123+
};
124+
125+
const { container } = render(<Demo />);
126+
127+
for (let i = 0; i < 4; i++) {
128+
act(() => {
129+
fireEvent.click(container.querySelector('button'));
130+
});
131+
}
132+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);
133+
134+
act(() => {
135+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }));
136+
});
137+
138+
// Hover the topmost notification wrapper
139+
const wrappers = document.querySelectorAll('.rc-notification-notice-wrapper');
140+
act(() => {
141+
fireEvent.mouseEnter(wrappers[wrappers.length - 1]);
142+
});
143+
144+
// Timers should be paused while hovering
145+
act(() => {
146+
vi.advanceTimersByTime(5000);
147+
});
148+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);
149+
150+
// Close the hovered notification via close button
151+
act(() => {
152+
const closeButtons = document.querySelectorAll('.rc-notification-notice-close');
153+
fireEvent.click(closeButtons[closeButtons.length - 1]);
154+
});
155+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);
156+
157+
// Flush requestAnimationFrame so hover state recalculation takes effect
158+
act(() => {
159+
vi.advanceTimersByTime(100);
160+
});
161+
162+
// Remaining notices should auto-close since hover state was properly cleared
163+
act(() => {
164+
vi.advanceTimersByTime(2000);
165+
});
166+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(0);
167+
expect(onClose).toHaveBeenCalledTimes(4);
168+
});
169+
170+
it('should keep hover state when mouse is still over a notice after close', () => {
171+
const mockRect = {
172+
top: 0,
173+
left: 0,
174+
bottom: 200,
175+
right: 300,
176+
width: 300,
177+
height: 200,
178+
x: 0,
179+
y: 0,
180+
toJSON: () => {},
181+
};
182+
const spy = vi
183+
.spyOn(Element.prototype, 'getBoundingClientRect')
184+
.mockReturnValue(mockRect as DOMRect);
185+
186+
const Demo = () => {
187+
const [api, holder] = useNotification({
188+
stack: { threshold: 3 },
189+
});
190+
return (
191+
<>
192+
<button
193+
type="button"
194+
onClick={() => {
195+
api.open({
196+
content: <div className="context-content">Test</div>,
197+
duration: 1,
198+
closable: true,
199+
});
200+
}}
201+
/>
202+
{holder}
203+
</>
204+
);
205+
};
206+
207+
const { container } = render(<Demo />);
208+
209+
for (let i = 0; i < 4; i++) {
210+
act(() => {
211+
fireEvent.click(container.querySelector('button'));
212+
});
213+
}
214+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);
215+
216+
// Mouse position inside the mocked bounding rect
217+
act(() => {
218+
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }));
219+
});
220+
221+
const wrappers = document.querySelectorAll('.rc-notification-notice-wrapper');
222+
act(() => {
223+
fireEvent.mouseEnter(wrappers[wrappers.length - 1]);
224+
});
225+
226+
// Close the hovered notification
227+
act(() => {
228+
const closeButtons = document.querySelectorAll('.rc-notification-notice-close');
229+
fireEvent.click(closeButtons[closeButtons.length - 1]);
230+
});
231+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);
232+
233+
// Flush RAF - mouse is within bounding rect so hover state should persist
234+
act(() => {
235+
vi.advanceTimersByTime(100);
236+
});
237+
238+
// Timers should still be paused because mouse is detected over a notice
239+
act(() => {
240+
vi.advanceTimersByTime(5000);
241+
});
242+
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);
243+
244+
spy.mockRestore();
245+
});
246+
});

0 commit comments

Comments
 (0)