diff --git a/src/Notification.tsx b/src/Notification.tsx index ad9e529c..03964b6d 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -63,6 +63,7 @@ export interface NotificationProps { pauseOnHover?: boolean; // Function + onHover?: (hovering: boolean) => void; onClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; @@ -99,6 +100,7 @@ const Notification = React.forwardRef((props, pauseOnHover = true, // Function + onHover, onClick, onMouseEnter, onMouseLeave, @@ -136,7 +138,18 @@ const Notification = React.forwardRef((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) { + onInternalHover(true); setHovering(true); if (pauseOnHover) { onPause(); @@ -145,6 +158,8 @@ const Notification = React.forwardRef((props, } function onInternalMouseLeave(event: React.MouseEvent) { + // Normal exits are handled by the list, including movement through its gaps. + hoveringRef.current = false; setHovering(false); if (pauseOnHover && !forcedHovering) { onResume(); diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index d0ff04b3..be0dc706 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -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'; @@ -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 { +export interface NotificationListConfig extends Omit { key: React.Key; placement?: Placement; times?: number; @@ -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 = (props) => { @@ -125,6 +126,7 @@ const NotificationListItem: React.FC = (props) => { pauseOnHover, setNodeSize, onNoticeClose, + onHover, ...restProps } = props; const { key, placement: itemPlacement, ...notificationConfig } = config; @@ -153,6 +155,7 @@ const NotificationListItem: React.FC = (props) => { }} hovering={stackEnabled && listHovering} pauseOnHover={config.pauseOnHover ?? pauseOnHover} + onHover={(hovering) => onHover(strKey, hovering)} onClose={() => { config.onClose?.(); onNoticeClose?.(key); @@ -195,6 +198,16 @@ const NotificationList: React.FC = (props) => { // ====================== Stack State ======================= const [stackEnabled, { offset, threshold }] = useStack(stackConfig); const [listHovering, setListHovering] = React.useState(false); + const hoveringKeyRef = React.useRef(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 ====================== @@ -252,6 +265,7 @@ const NotificationList: React.FC = (props) => { setListHovering(true); }} onMouseLeave={() => { + hoveringKeyRef.current = undefined; setListHovering(false); }} style={{ ...styles?.list, ...style }} @@ -303,6 +317,7 @@ const NotificationList: React.FC = (props) => { pauseOnHover={pauseOnHover} setNodeSize={setNodeSize} onNoticeClose={onNoticeClose} + onHover={onNoticeHover} /> ); }} diff --git a/tests/stack-hover.test.tsx b/tests/stack-hover.test.tsx new file mode 100644 index 00000000..5a3c20bf --- /dev/null +++ b/tests/stack-hover.test.tsx @@ -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[]) => ( + + ); + 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( + , + ); + fireEvent.mouseEnter(getByText('Notice 0')); + rerender(); + 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( + , + ); + fireEvent.mouseEnter(getByText('Notice 0')); + // A leaving notice can unmount after another notice receives mouseenter. + fireEvent.mouseEnter(getByText('Notice 1')); + rerender(); + expect(container.querySelector('.rc-notification-list-hovered')).not.toBeNull(); + rerender(); + 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( + , + ); + 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(); + expect(container.querySelector('.rc-notification-list-hovered')).not.toBeNull(); + fireEvent.mouseLeave(list); + expect(container.querySelector('.rc-notification-list-hovered')).toBeNull(); + }); +});