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
15 changes: 15 additions & 0 deletions src/Notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface NotificationProps {
pauseOnHover?: boolean;

// Function
onHover?: (hovering: boolean) => void;
onClick?: React.MouseEventHandler<HTMLDivElement>;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
Expand Down Expand Up @@ -99,6 +100,7 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>((props,
pauseOnHover = true,

// Function
onHover,
onClick,
onMouseEnter,
onMouseLeave,
Expand Down Expand Up @@ -136,7 +138,18 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>((props,
}, [forcedHovering, hovering, onPause, onResume, pauseOnHover]);

// ========================= Hover ==========================
const hoveringRef = React.useRef(false);
const onInternalHover = useEvent((nextHovering: boolean) => {
if (nextHovering || hoveringRef.current) {
onHover?.(nextHovering);
}
hoveringRef.current = nextHovering;
});

React.useEffect(() => () => onInternalHover(false), [onInternalHover]);

function onInternalMouseEnter(event: React.MouseEvent<HTMLDivElement>) {
onInternalHover(true);
setHovering(true);
if (pauseOnHover) {
onPause();
Expand All @@ -145,6 +158,8 @@ const Notification = React.forwardRef<HTMLDivElement, NotificationProps>((props,
}

function onInternalMouseLeave(event: React.MouseEvent<HTMLDivElement>) {
// Normal exits are handled by the list, including movement through its gaps.
hoveringRef.current = false;
setHovering(false);
if (pauseOnHover && !forcedHovering) {
onResume();
Expand Down
19 changes: 17 additions & 2 deletions src/NotificationList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CSSMotionList } from '@rc-component/motion';
import type { CSSMotionProps } from '@rc-component/motion';
import { useComposeRef } from '@rc-component/util';
import { useComposeRef, useEvent } from '@rc-component/util';
import { clsx } from 'clsx';
import * as React from 'react';
import useListPosition from '../hooks/useListPosition';
Expand All @@ -17,7 +17,7 @@ import Content from './Content';
export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight';
export type { StackConfig } from '../hooks/useStack';

export interface NotificationListConfig extends Omit<NotificationProps, 'prefixCls'> {
export interface NotificationListConfig extends Omit<NotificationProps, 'prefixCls' | 'onHover'> {
key: React.Key;
placement?: Placement;
times?: number;
Expand Down Expand Up @@ -108,6 +108,7 @@ interface NotificationListItemProps {
pauseOnHover?: boolean;
setNodeSize: (key: string, node: HTMLDivElement | null) => void;
onNoticeClose?: (key: React.Key) => void;
onHover: (key: string, hovering: boolean) => void;
}

const NotificationListItem: React.FC<NotificationListItemProps> = (props) => {
Expand All @@ -125,6 +126,7 @@ const NotificationListItem: React.FC<NotificationListItemProps> = (props) => {
pauseOnHover,
setNodeSize,
onNoticeClose,
onHover,
...restProps
} = props;
const { key, placement: itemPlacement, ...notificationConfig } = config;
Expand Down Expand Up @@ -153,6 +155,7 @@ const NotificationListItem: React.FC<NotificationListItemProps> = (props) => {
}}
hovering={stackEnabled && listHovering}
pauseOnHover={config.pauseOnHover ?? pauseOnHover}
onHover={(hovering) => onHover(strKey, hovering)}
onClose={() => {
config.onClose?.();
onNoticeClose?.(key);
Expand Down Expand Up @@ -195,6 +198,16 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {
// ====================== Stack State =======================
const [stackEnabled, { offset, threshold }] = useStack(stackConfig);
const [listHovering, setListHovering] = React.useState(false);
const hoveringKeyRef = React.useRef<string | undefined>(undefined);
const onNoticeHover = useEvent((key: string, hovering: boolean) => {
if (hovering) {
hoveringKeyRef.current = key;
setListHovering(true);
} else if (hoveringKeyRef.current === key) {
hoveringKeyRef.current = undefined;
setListHovering(false);
}
});
const expanded = stackEnabled && (listHovering || keys.length <= threshold);

// ====================== Stack Layout ======================
Expand Down Expand Up @@ -252,6 +265,7 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {
setListHovering(true);
}}
onMouseLeave={() => {
hoveringKeyRef.current = undefined;
setListHovering(false);
}}
style={{ ...styles?.list, ...style }}
Expand Down Expand Up @@ -303,6 +317,7 @@ const NotificationList: React.FC<NotificationListProps> = (props) => {
pauseOnHover={pauseOnHover}
setNodeSize={setNodeSize}
onNoticeClose={onNoticeClose}
onHover={onNoticeHover}
/>
);
}}
Expand Down
103 changes: 103 additions & 0 deletions tests/stack-hover.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { act, fireEvent, render } from '@testing-library/react';
import React from 'react';
import NotificationList from '../src/NotificationList';
import type { NotificationListConfig } from '../src/NotificationList';

function notices(count: number): NotificationListConfig[] {
return Array.from({ length: count }, (_, key) => ({
key,
description: `Notice ${key}`,
duration: 0,
}));
}

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

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

it.each([
[4, 3],
[5, 3],
[4, 2],
])(
'resumes remaining duration when the hovered notice unmounts (%i notices, threshold %i)',
(count, threshold) => {
const onClose = vi.fn();
const configs = notices(count).map((config) => ({
...config,
duration: config.key === 1 ? 0 : 1,
onClose,
}));
const renderList = (configList: NotificationListConfig[]) => (
<NotificationList placement="topRight" stack={{ threshold }} configList={configList} />
);
const { container, getByText, rerender } = render(renderList(configs));
step(320);
fireEvent.mouseEnter(getByText('Notice 0'));
step(2000);
expect(onClose).not.toHaveBeenCalled();
rerender(renderList(configs.slice(1)));
expect(container.querySelector('.rc-notification-list-hovered')).toBeNull();
expect(!!container.querySelector('.rc-notification-stack-expanded')).toBe(
count - 1 <= threshold,
);
step(400);
expect(onClose).not.toHaveBeenCalled();
step(400);
expect(onClose).toHaveBeenCalledTimes(count - 2);
},
);

it('keeps hovering when an unrelated notice unmounts', () => {
const configs = notices(5);
const { container, getByText, rerender } = render(
<NotificationList placement="topRight" stack configList={configs} />,
);
fireEvent.mouseEnter(getByText('Notice 0'));
rerender(<NotificationList placement="topRight" stack configList={configs.slice(0, -1)} />);
expect(container.querySelector('.rc-notification-list-hovered')).not.toBeNull();
expect(container.querySelector('.rc-notification-stack-expanded')).not.toBeNull();
});

it('does not let an old hovered notice clear a newer hover', () => {
const configs = notices(5);
const { container, getByText, rerender } = render(
<NotificationList placement="topRight" stack configList={configs} />,
);
fireEvent.mouseEnter(getByText('Notice 0'));
// A leaving notice can unmount after another notice receives mouseenter.
fireEvent.mouseEnter(getByText('Notice 1'));
rerender(<NotificationList placement="topRight" stack configList={configs.slice(1)} />);
expect(container.querySelector('.rc-notification-list-hovered')).not.toBeNull();
rerender(<NotificationList placement="topRight" stack configList={configs.slice(2)} />);
expect(container.querySelector('.rc-notification-list-hovered')).toBeNull();
});

it('preserves hover in list gaps and forwards mouse callbacks', () => {
const onMouseEnter = vi.fn();
const onMouseLeave = vi.fn();
const configs = notices(5);
configs[0] = { ...configs[0], onMouseEnter, onMouseLeave };
const { container, getByText, rerender } = render(
<NotificationList placement="topRight" stack configList={configs} />,
);
const list = container.querySelector('.rc-notification-list');
const notice = getByText('Notice 0').closest('.rc-notification-notice');
fireEvent.mouseEnter(notice);
fireEvent.mouseLeave(notice, { relatedTarget: list });
expect(onMouseEnter).toHaveBeenCalledTimes(1);
expect(onMouseLeave).toHaveBeenCalledTimes(1);
rerender(<NotificationList placement="topRight" stack configList={configs.slice(1)} />);
expect(container.querySelector('.rc-notification-list-hovered')).not.toBeNull();
fireEvent.mouseLeave(list);
expect(container.querySelector('.rc-notification-list-hovered')).toBeNull();
});
});
Loading