Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions src/NoticeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ const NoticeList: FC<NoticeListProps> = (props) => {
const { classNames: ctxCls } = useContext(NotificationContext);

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

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

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

// Track mouse position globally when in stack mode
useEffect(() => {
if (!stack) return;

const handleMouseMove = (e: MouseEvent) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY };
};

document.addEventListener('mousemove', handleMouseMove, { passive: true });
return () => document.removeEventListener('mousemove', handleMouseMove);
}, [stack]);

// Clean hover key
useEffect(() => {
if (stack && hoverKeys.length > 1) {
setHoverKeys((prev) =>
prev.filter((key) => keys.some(({ key: dataKey }) => key === dataKey)),
);
// Only update if there's a change to avoid unnecessary re-renders
setHoverKeys((prev) => {
const filtered = prev.filter((key) => keys.some(({ key: dataKey }) => key === dataKey));
return filtered.length === prev.length ? prev : filtered;
});
}
}, [hoverKeys, keys, stack]);

// Check mouse position when keys change (notification list updates)
useEffect(() => {
if (!stack || !mousePositionRef.current) return;

// Use requestAnimationFrame to wait for DOM updates
const rafId = requestAnimationFrame(() => {
const mousePos = mousePositionRef.current;
if (!mousePos) return;

const newHoverKeys: string[] = [];
keys.forEach(({ key: strKey }) => {
const element = dictRef.current[strKey];
if (element) {
const rect = element.getBoundingClientRect();
if (
mousePos.x >= rect.left &&
mousePos.x <= rect.right &&
mousePos.y >= rect.top &&
mousePos.y <= rect.bottom
) {
newHoverKeys.push(strKey);
}
}
});

// Only update if there's a change to avoid unnecessary re-renders
setHoverKeys((prev) => {
if (prev.length === newHoverKeys.length && prev.every((k, i) => k === newHoverKeys[i])) {
return prev;
}
return newHoverKeys;
});
});

return () => cancelAnimationFrame(rafId);
}, [keys, stack]);

// Force update latest notice
useEffect(() => {
if (stack && dictRef.current[keys[keys.length - 1]?.key]) {
Expand Down
159 changes: 158 additions & 1 deletion tests/stack.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useNotification } from '../src';
import { fireEvent, render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';

require('../assets/index.less');
Expand Down Expand Up @@ -87,3 +87,160 @@ describe('stack', () => {
expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy();
});
});

describe('hover state after closing notice in stack', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('should clear hover state and resume timers when closing a hovered notice', () => {
const onClose = vi.fn();

const Demo = () => {
const [api, holder] = useNotification({
stack: { threshold: 3 },
});
return (
<>
<button
type="button"
onClick={() => {
api.open({
content: <div className="context-content">Test</div>,
duration: 1,
closable: true,
onClose,
});
}}
/>
{holder}
</>
);
};

const { container } = render(<Demo />);

for (let i = 0; i < 4; i++) {
act(() => {
fireEvent.click(container.querySelector('button'));
});
}
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);

act(() => {
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }));
});

// Hover the topmost notification wrapper
const wrappers = document.querySelectorAll('.rc-notification-notice-wrapper');
act(() => {
fireEvent.mouseEnter(wrappers[wrappers.length - 1]);
});

// Timers should be paused while hovering
act(() => {
vi.advanceTimersByTime(5000);
});
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);

// Close the hovered notification via close button
act(() => {
const closeButtons = document.querySelectorAll('.rc-notification-notice-close');
fireEvent.click(closeButtons[closeButtons.length - 1]);
});
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);

// Flush requestAnimationFrame so hover state recalculation takes effect
act(() => {
vi.advanceTimersByTime(100);
});

// Remaining notices should auto-close since hover state was properly cleared
act(() => {
vi.advanceTimersByTime(2000);
});
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(0);
expect(onClose).toHaveBeenCalledTimes(4);
});

it('should keep hover state when mouse is still over a notice after close', () => {
const mockRect = {
top: 0,
left: 0,
bottom: 200,
right: 300,
width: 300,
height: 200,
x: 0,
y: 0,
toJSON: () => {},
};
const spy = vi
.spyOn(Element.prototype, 'getBoundingClientRect')
.mockReturnValue(mockRect as DOMRect);

const Demo = () => {
const [api, holder] = useNotification({
stack: { threshold: 3 },
});
return (
<>
<button
type="button"
onClick={() => {
api.open({
content: <div className="context-content">Test</div>,
duration: 1,
closable: true,
});
}}
/>
{holder}
</>
);
};

const { container } = render(<Demo />);

for (let i = 0; i < 4; i++) {
act(() => {
fireEvent.click(container.querySelector('button'));
});
}
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);

// Mouse position inside the mocked bounding rect
act(() => {
document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 100 }));
});

const wrappers = document.querySelectorAll('.rc-notification-notice-wrapper');
act(() => {
fireEvent.mouseEnter(wrappers[wrappers.length - 1]);
});

// Close the hovered notification
act(() => {
const closeButtons = document.querySelectorAll('.rc-notification-notice-close');
fireEvent.click(closeButtons[closeButtons.length - 1]);
});
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);

// Flush RAF - mouse is within bounding rect so hover state should persist
act(() => {
vi.advanceTimersByTime(100);
});

// Timers should still be paused because mouse is detected over a notice
act(() => {
vi.advanceTimersByTime(5000);
});
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);

spy.mockRestore();
});
});