Skip to content
Closed
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
20 changes: 20 additions & 0 deletions src/NotificationList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,27 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {
// ====================== Stack State =======================
const [stackEnabled, { offset, threshold }] = useStack(stackConfig);
const [listHovering, setListHovering] = React.useState(false);
const listRef = React.useRef<HTMLDivElement>(null);
const expanded = stackEnabled && (listHovering || keys.length <= threshold);

React.useEffect(() => {
const listNode = listRef.current;
if (!listHovering || !listNode) {
return;
}

// Removing a hovered notice can prevent React's mouseleave from firing.
const onMouseMove = (event: MouseEvent) => {
// Closed shadow roots hide the list from the document event's path.
if (!event.composedPath().includes(listNode) && !listNode.matches(':hover')) {
setListHovering(false);
}
};
const { ownerDocument } = listNode;
ownerDocument.addEventListener('mousemove', onMouseMove, true);
return () => ownerDocument.removeEventListener('mousemove', onMouseMove, true);
}, [listHovering]);

// ====================== Stack Layout ======================
const stackPosition = React.useMemo<StackConfig | undefined>(() => {
if (!stackEnabled || expanded) {
Expand Down Expand Up @@ -235,6 +254,7 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {

return (
<div
ref={listRef}
className={clsx(
prefixCls,
listPrefixCls,
Expand Down
134 changes: 134 additions & 0 deletions tests/stack-hover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { useNotification } from '../src';
import type { NotificationAPI } from '../src';

describe('stack hover recovery', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

function step(time: number) {
act(() => {
for (let elapsed = 0; elapsed < time; elapsed += 16) {
vi.advanceTimersByTime(16);
}
});
}

it.each(['open', 'closed'] as const)('preserves hover within a %s shadow container', (mode) => {
const host = document.createElement('div');
document.body.appendChild(host);
const outerRoot = host.attachShadow({ mode: 'closed' });
const innerHost = document.createElement('div');
const outside = document.createElement('button');
outerRoot.append(innerHost, outside);
const shadowRoot = innerHost.attachShadow({ mode });
let api: NotificationAPI;
const Demo = () => {
const [instance, holder] = useNotification({
getContainer: () => shadowRoot,
stack: { threshold: 1 },
pauseOnHover: true,
});
api = instance;
return holder;
};
const { unmount } = render(<Demo />);
for (const destination of [outside, document.body, innerHost, host]) {
act(() => {
for (let key = 0; key < 3; key += 1) {
api.open({ key, description: `Notice ${key}`, duration: 1, closable: true });
}
});
const notices = shadowRoot.querySelectorAll('.rc-notification-notice');
expect(notices).toHaveLength(3);
const list = shadowRoot.querySelector('.rc-notification-list');
let pointerInside = true;
const matches = list.matches.bind(list);
// jsdom has no pointer hit testing. Model only its native :hover result.
const hoverState = vi
.spyOn(list, 'matches')
.mockImplementation((selector) =>
selector === ':hover' ? pointerInside : matches(selector),
);
fireEvent.mouseEnter(notices[0]);
fireEvent.mouseMove(notices[1]);
expect(shadowRoot.querySelector('.rc-notification-list-hovered')).not.toBeNull();
step(2000);
expect(shadowRoot.querySelectorAll('.rc-notification-notice')).toHaveLength(3);
fireEvent.click(notices[0].querySelector('button'));
pointerInside = false;
fireEvent.mouseMove(destination);
expect(shadowRoot.querySelector('.rc-notification-list-hovered')).toBeNull();
step(1100);
expect(shadowRoot.querySelectorAll('.rc-notification-notice')).toHaveLength(0);
hoverState.mockRestore();
}
unmount();
host.remove();
});

it.each([
[4, 3],
[5, 3],
[4, 2],
])('resumes after removing a hovered notice (%i notices, threshold %i)', (count, threshold) => {
let api: NotificationAPI;
const closed: number[] = [];
const Demo = () => {
const [instance, holder] = useNotification({ stack: { threshold }, pauseOnHover: true });
api = instance;
return (
<>
{holder}
<button onMouseMove={(event) => event.stopPropagation()}>Outside</button>
</>
);
};
const { getByText, unmount } = render(<Demo />);

for (let round = 0; round < 2; round += 1) {
act(() => {
for (let key = 0; key < count; key += 1) {
api.open({
key,
description: `Existing content ${key}`,
duration: key === 1 ? 0 : 1,
closable: true,
onClose: () => closed.push(key),
});
}
});
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(count);
step(320);
const firstNotice = getByText('Existing content 0').closest('.rc-notification-notice');
fireEvent.mouseEnter(firstNotice);
fireEvent.click(firstNotice.querySelector('button'));
expect(getByText('Existing content 1')).toBeInTheDocument();
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(count - 1);

// Removing the hovered DOM node can omit React's mouseleave event.
step(2000);
fireEvent.mouseMove(getByText('Existing content 2'));
step(2000);
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(count - 1);

fireEvent.mouseMove(getByText('Outside'));
expect(document.querySelector('.rc-notification-list-hovered')).toBeNull();
expect(!!document.querySelector('.rc-notification-stack-expanded')).toBe(
count - 1 <= threshold,
);
step(400);
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(count - 1);
step(400);
expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(1);
expect(getByText('Existing content 1')).toBeInTheDocument();
expect(closed.slice(round * (count - 1))).toEqual([
0,
...Array.from({ length: count - 2 }, (_, index) => index + 2),
]);
}
unmount();
fireEvent.mouseMove(document.body);
});
});
Loading