From 2ea149b0cd065f96072fda2cf782104d42d3561a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 11 Mar 2026 22:53:45 +0800 Subject: [PATCH 01/76] refactor: move src to legacy and create re-export index for compatibility --- src/index.ts | 8 ++++---- src/{ => legacy}/Notice.tsx | 0 src/{ => legacy}/NoticeList.tsx | 0 src/{ => legacy}/NotificationProvider.tsx | 0 src/{ => legacy}/Notifications.tsx | 0 src/{ => legacy}/hooks/useNotification.tsx | 0 src/{ => legacy}/hooks/useStack.ts | 0 src/{ => legacy}/interface.ts | 0 tests/hooks.test.tsx | 3 +-- 9 files changed, 5 insertions(+), 6 deletions(-) rename src/{ => legacy}/Notice.tsx (100%) rename src/{ => legacy}/NoticeList.tsx (100%) rename src/{ => legacy}/NotificationProvider.tsx (100%) rename src/{ => legacy}/Notifications.tsx (100%) rename src/{ => legacy}/hooks/useNotification.tsx (100%) rename src/{ => legacy}/hooks/useStack.ts (100%) rename src/{ => legacy}/interface.ts (100%) diff --git a/src/index.ts b/src/index.ts index f5928e4..35b7e54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ -import useNotification from './hooks/useNotification'; -import Notice from './Notice'; -import type { NotificationAPI, NotificationConfig } from './hooks/useNotification'; -import NotificationProvider from './NotificationProvider'; +import useNotification from './legacy/hooks/useNotification'; +import Notice from './legacy/Notice'; +import type { NotificationAPI, NotificationConfig } from './legacy/hooks/useNotification'; +import NotificationProvider from './legacy/NotificationProvider'; export { useNotification, Notice, NotificationProvider }; export type { NotificationAPI, NotificationConfig }; diff --git a/src/Notice.tsx b/src/legacy/Notice.tsx similarity index 100% rename from src/Notice.tsx rename to src/legacy/Notice.tsx diff --git a/src/NoticeList.tsx b/src/legacy/NoticeList.tsx similarity index 100% rename from src/NoticeList.tsx rename to src/legacy/NoticeList.tsx diff --git a/src/NotificationProvider.tsx b/src/legacy/NotificationProvider.tsx similarity index 100% rename from src/NotificationProvider.tsx rename to src/legacy/NotificationProvider.tsx diff --git a/src/Notifications.tsx b/src/legacy/Notifications.tsx similarity index 100% rename from src/Notifications.tsx rename to src/legacy/Notifications.tsx diff --git a/src/hooks/useNotification.tsx b/src/legacy/hooks/useNotification.tsx similarity index 100% rename from src/hooks/useNotification.tsx rename to src/legacy/hooks/useNotification.tsx diff --git a/src/hooks/useStack.ts b/src/legacy/hooks/useStack.ts similarity index 100% rename from src/hooks/useStack.ts rename to src/legacy/hooks/useStack.ts diff --git a/src/interface.ts b/src/legacy/interface.ts similarity index 100% rename from src/interface.ts rename to src/legacy/interface.ts diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 42a372e..78c031d 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,8 +1,7 @@ import React, { ReactElement } from 'react'; import { render, fireEvent, act } from '@testing-library/react'; -import { useNotification } from '../src'; +import { useNotification, NotificationProvider } from '../src'; import type { NotificationAPI, NotificationConfig } from '../src'; -import NotificationProvider from '../src/NotificationProvider'; require('../assets/index.less'); From 2b539d50c13ac18ff7be7291eb4237d35a33e35a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 11 Mar 2026 23:26:12 +0800 Subject: [PATCH 02/76] feat: add Notification and NotificationList components --- src/Notification.tsx | 31 +++++++++++++++++++++++++++++++ src/NotificationList.tsx | 28 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 src/Notification.tsx create mode 100644 src/NotificationList.tsx diff --git a/src/Notification.tsx b/src/Notification.tsx new file mode 100644 index 0000000..a7bf589 --- /dev/null +++ b/src/Notification.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +export interface NotificationProps { + children?: React.ReactNode; + icon?: React.ReactNode; + title?: React.ReactNode; + content?: React.ReactNode; + actions?: React.ReactNode; + close?: React.ReactNode; +} + +const Notification = React.forwardRef((props, ref) => { + const { children, icon, title, content, actions, close, ...restProps } = props; + return ( +
+ {icon &&
{icon}
} + {title &&
{title}
} + {close && ( + + )} + {content &&
{content}
} + {actions &&
{actions}
} +
+ ); +}); + +Notification.displayName = 'Notification'; + +export default Notification; diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx new file mode 100644 index 0000000..186941a --- /dev/null +++ b/src/NotificationList.tsx @@ -0,0 +1,28 @@ +import type { CSSMotionProps } from '@rc-component/motion'; +import * as React from 'react'; + +export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; + +export type StackConfig = + | boolean + | { + threshold?: number; + offset?: number; + gap?: number; + }; + +export interface NotificationListProps { + prefixCls?: string; + getContainer?: () => HTMLElement | ShadowRoot; + placement?: Placement; + pauseOnHover?: boolean; + stack?: StackConfig; + maxCount?: number; + motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); +} + +const NotificationList: React.FC = () => { + return null; +}; + +export default NotificationList; From e1b36cdd81aa7af4fb36a7a1d1b48258715bacbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 12 Mar 2026 00:08:53 +0800 Subject: [PATCH 03/76] refactor: simplify Notification component and add new props - Remove unused props: children, icon, title - Add duration, pauseOnHover, onClick props - Simplify component structure Co-Authored-By: Claude Opus 4.6 --- src/Notification.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Notification.tsx b/src/Notification.tsx index a7bf589..b4efde3 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -1,26 +1,24 @@ import * as React from 'react'; export interface NotificationProps { - children?: React.ReactNode; - icon?: React.ReactNode; - title?: React.ReactNode; content?: React.ReactNode; actions?: React.ReactNode; close?: React.ReactNode; + duration?: number | false | null; + pauseOnHover?: boolean; + onClick?: React.MouseEventHandler; } const Notification = React.forwardRef((props, ref) => { - const { children, icon, title, content, actions, close, ...restProps } = props; + const { content, actions, close, ...restProps } = props; return (
- {icon &&
{icon}
} - {title &&
{title}
} + {content} {close && ( )} - {content &&
{content}
} {actions &&
{actions}
}
); From abea35d111af584ed48fe849e205aeb85b9207e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 12 Mar 2026 00:43:43 +0800 Subject: [PATCH 04/76] feat: add useNoticeTimer hook and enhance Notification component - Add useNoticeTimer hook for auto-close timer with pause/resume support - Add onClose callback prop for timeout-based closing - Add pauseOnHover support (default: true) to pause timer on mouse hover - Add onClick handler support - Set default duration to 4.5 seconds Co-Authored-By: Claude Opus 4.6 --- src/Notification.tsx | 27 +++++++++++++++++++---- src/hooks/useNoticeTimer.ts | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 src/hooks/useNoticeTimer.ts diff --git a/src/Notification.tsx b/src/Notification.tsx index b4efde3..bf54c64 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -1,21 +1,40 @@ import * as React from 'react'; +import useNoticeTimer from './hooks/useNoticeTimer'; +import { useEvent } from '@rc-component/util'; export interface NotificationProps { content?: React.ReactNode; actions?: React.ReactNode; close?: React.ReactNode; - duration?: number | false | null; + duration?: number | false; pauseOnHover?: boolean; onClick?: React.MouseEventHandler; + /** Callback when notification is closed by timeout */ + onClose?: () => void; } const Notification = React.forwardRef((props, ref) => { - const { content, actions, close, ...restProps } = props; + const { content, actions, close, duration = 4.5, pauseOnHover = true, onClick, onClose } = props; + + // ========================= Close ========================== + const onEventClose = useEvent(onClose); + + // ======================== Duration ======================== + const [onResume, onPause] = useNoticeTimer(duration, onEventClose); + + // ========================= Render ========================= return ( -
+
{content} {close && ( - )} diff --git a/src/hooks/useNoticeTimer.ts b/src/hooks/useNoticeTimer.ts new file mode 100644 index 0000000..9616471 --- /dev/null +++ b/src/hooks/useNoticeTimer.ts @@ -0,0 +1,44 @@ +import * as React from 'react'; + +export default function useNoticeTimer(duration: number | false, onClose: VoidFunction) { + // Normalize: `false` means no auto-close + const mergedDuration: number = typeof duration === 'number' ? duration : 0; + + const startTimestampRef = React.useRef(0); + const leftTimeRef = React.useRef(mergedDuration * 1000); + const timerRef = React.useRef(); + + const clear = () => { + clearTimeout(timerRef.current); + }; + + const onResume = () => { + clear(); + + // Only start timer when there is remaining time + if (leftTimeRef.current > 0) { + startTimestampRef.current = Date.now(); + timerRef.current = setTimeout(() => { + onClose(); + }, leftTimeRef.current); + } + }; + + const onPause = () => { + clear(); + + // Record how much time is left so onResume can continue from here + leftTimeRef.current -= Date.now() - startTimestampRef.current; + }; + + React.useEffect(() => { + // Reset remaining time whenever duration changes, then (re)start the timer + leftTimeRef.current = mergedDuration * 1000; + onResume(); + + // Clear the timer on unmount or before next effect run + return clear; + }, []); + + return [onResume, onPause] as const; +} From fb51848259c87d0d99678789461541c9416a3e0b Mon Sep 17 00:00:00 2001 From: zombieJ Date: Fri, 13 Mar 2026 00:36:22 +0800 Subject: [PATCH 05/76] refactor notice timer hook --- src/Notification.tsx | 9 +++- src/hooks/useNoticeTimer.ts | 89 ++++++++++++++++++++++--------------- 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/src/Notification.tsx b/src/Notification.tsx index bf54c64..7f9954e 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -20,11 +20,16 @@ const Notification = React.forwardRef((props, const onEventClose = useEvent(onClose); // ======================== Duration ======================== - const [onResume, onPause] = useNoticeTimer(duration, onEventClose); + const [onResume, onPause] = useNoticeTimer(duration, onEventClose, () => {}); // ========================= Render ========================= return ( -
+
{content} {close && ( + +
+ + + + ); +}; + +export default Demo; diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 0df2482..5c67c3e 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -70,32 +70,37 @@ const NotificationList: React.FC = (props) => { className={clsx(listPrefixCls, `${listPrefixCls}-${placement}`)} {...placementMotion} > - {({ config, className, style }, nodeRef) => ( - - )} + {({ config, className, style }, nodeRef) => { + const { key, ...notificationConfig } = config; + + return ( + + ); + }} ); }; From a20277c46b7fae236462a7e0040d552596874182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 16 Mar 2026 15:40:06 +0800 Subject: [PATCH 10/76] refactor: separate notification list offset and motion --- assets/geek.less | 26 ++++- src/NotificationList.tsx | 232 +++++++++++++++++++++++++++++++-------- 2 files changed, 212 insertions(+), 46 deletions(-) diff --git a/assets/geek.less b/assets/geek.less index 3ebb422..916a6c8 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -10,14 +10,34 @@ right: 0; z-index: 1000; width: 360px; - max-height: 100vh; + height: 100vh; padding: 24px; box-sizing: border-box; + pointer-events: none; + overflow: hidden; + } + + &-list-content { + position: relative; display: flex; flex-direction: column; + width: 100%; pointer-events: none; - overflow-y: auto; - overflow-x: hidden; + will-change: transform; + } + + &-list-item { + position: absolute; + top: 0; + left: 0; + box-sizing: border-box; + width: 100%; + transform: translate3d(var(--notification-x, 0), var(--notification-y, 0), 0); + transition: transform @notificationMotionDuration @notificationMotionEase; + } + + &-list-item-motion { + width: 100%; } &-notice { diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 5c67c3e..2cc64dd 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -7,6 +7,7 @@ import Notification, { type NotificationProps, type NotificationStyles, } from './Notification'; +import useListPosition from './hooks/useListPosition'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; @@ -35,6 +36,29 @@ export interface NotificationListProps { motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); } +function clampScrollOffset(offset: number, maxScroll: number) { + return Math.min(0, Math.max(-maxScroll, offset)); +} + +function assignRef(ref: React.Ref, value: T | null) { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + (ref as React.MutableRefObject).current = value; + } +} + +function getNoticeStyle(nodePosition?: { x: number; y: number }): React.CSSProperties | undefined { + if (!nodePosition) { + return undefined; + } + + return { + '--notification-x': `${nodePosition.x}px`, + '--notification-y': `${nodePosition.y}px`, + } as React.CSSProperties; +} + const NotificationList: React.FC = (props) => { const { configList = [], @@ -48,60 +72,182 @@ const NotificationList: React.FC = (props) => { } = props; // ========================== Data ========================== - const mergedConfigList = - typeof maxCount === 'number' && maxCount > 0 ? configList.slice(-maxCount) : configList; + const mergedConfigList = React.useMemo(() => { + const list = + typeof maxCount === 'number' && maxCount > 0 ? configList.slice(-maxCount) : configList; - const keys = mergedConfigList.map((config) => ({ - config, - key: String(config.key), - })); + return list.slice().reverse(); + }, [configList, maxCount]); + + const keys = React.useMemo( + () => + mergedConfigList.map((config) => ({ + config, + key: String(config.key), + })), + [mergedConfigList], + ); + const keyList = React.useMemo(() => keys.map(({ key }) => key), [keys]); // ========================= Motion ========================= const placementMotion = typeof motion === 'function' ? motion(placement) : motion; + // ========================= Scroll ========================= + const viewportRef = React.useRef(null); + const contentRef = React.useRef(null); + const prevKeyListRef = React.useRef(keyList); + const prevNotificationPositionRef = React.useRef>( + new Map(), + ); + const scrollOffsetRef = React.useRef(0); + const [scrollOffset, setScrollOffset] = React.useState(0); + const [notificationPosition, setNodeSize] = useListPosition(mergedConfigList); + + const syncScrollOffset = React.useCallback((nextOffset: number) => { + const viewportHeight = viewportRef.current?.clientHeight ?? 0; + const measuredContentHeight = contentRef.current?.scrollHeight ?? 0; + const maxScroll = Math.max(measuredContentHeight - viewportHeight, 0); + const mergedOffset = clampScrollOffset(nextOffset, maxScroll); + + scrollOffsetRef.current = mergedOffset; + setScrollOffset((prev) => (prev === mergedOffset ? prev : mergedOffset)); + }, []); + + React.useLayoutEffect(() => { + const prevKeyList = prevKeyListRef.current; + const prevNotificationPosition = prevNotificationPositionRef.current; + + if (scrollOffsetRef.current < 0) { + const prependCount = prevKeyList.length + ? keyList.findIndex((key) => key === prevKeyList[0]) + : -1; + const removedCount = keyList.length ? prevKeyList.findIndex((key) => key === keyList[0]) : -1; + + if (prependCount > 0) { + const prependHeight = notificationPosition.get(prevKeyList[0])?.y ?? 0; + syncScrollOffset(scrollOffsetRef.current - prependHeight); + } else if (removedCount > 0) { + const removedHeight = keyList[0] ? (prevNotificationPosition.get(keyList[0])?.y ?? 0) : 0; + syncScrollOffset(scrollOffsetRef.current + removedHeight); + } else { + syncScrollOffset(scrollOffsetRef.current); + } + } else { + syncScrollOffset(scrollOffsetRef.current); + } + + prevKeyListRef.current = keyList; + prevNotificationPositionRef.current = new Map(notificationPosition); + }, [keyList, notificationPosition, syncScrollOffset]); + + React.useLayoutEffect(() => { + const viewportNode = viewportRef.current; + const contentNode = contentRef.current; + + if (!viewportNode || !contentNode || typeof ResizeObserver === 'undefined') { + return; + } + + const resizeObserver = new ResizeObserver(() => { + syncScrollOffset(scrollOffsetRef.current); + }); + + resizeObserver.observe(viewportNode); + resizeObserver.observe(contentNode); + + return () => { + resizeObserver.disconnect(); + }; + }, [syncScrollOffset]); + + const onWheel = React.useCallback( + (event: React.WheelEvent) => { + const viewportHeight = viewportRef.current?.clientHeight ?? 0; + const measuredContentHeight = contentRef.current?.scrollHeight ?? 0; + const maxScroll = Math.max(measuredContentHeight - viewportHeight, 0); + + if (!maxScroll) { + return; + } + + const nextOffset = clampScrollOffset(scrollOffsetRef.current - event.deltaY, maxScroll); + + if (nextOffset !== scrollOffsetRef.current) { + event.preventDefault(); + syncScrollOffset(nextOffset); + } + }, + [syncScrollOffset], + ); + // ========================= Render ========================= const listPrefixCls = `${prefixCls}-list`; + const itemPrefixCls = `${listPrefixCls}-item`; + const motionPrefixCls = `${itemPrefixCls}-motion`; return ( - - {({ config, className, style }, nodeRef) => { - const { key, ...notificationConfig } = config; - - return ( - - ); - }} - +
+ + {({ config, className, style }, nodeRef) => { + const { key, ...notificationConfig } = config; + const strKey = String(key); + + return ( +
{ + setNodeSize(strKey, node); + }} + style={{ + ...getNoticeStyle(notificationPosition.get(strKey)), + }} + > +
{ + assignRef(nodeRef, node); + }} + className={clsx(motionPrefixCls, className)} + style={style} + > + +
+
+ ); + }} +
+
+
); }; From f95d6336b41afe13a7d52d8ec18480b0a31f67a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 16 Mar 2026 15:42:11 +0800 Subject: [PATCH 11/76] fix: preserve notification offset while leaving --- src/NotificationList.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 2cc64dd..4005e7b 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -99,6 +99,9 @@ const NotificationList: React.FC = (props) => { const prevNotificationPositionRef = React.useRef>( new Map(), ); + const notificationPositionCacheRef = React.useRef>( + new Map(), + ); const scrollOffsetRef = React.useRef(0); const [scrollOffset, setScrollOffset] = React.useState(0); const [notificationPosition, setNodeSize] = useListPosition(mergedConfigList); @@ -113,6 +116,12 @@ const NotificationList: React.FC = (props) => { setScrollOffset((prev) => (prev === mergedOffset ? prev : mergedOffset)); }, []); + React.useLayoutEffect(() => { + notificationPosition.forEach((position, key) => { + notificationPositionCacheRef.current.set(key, position); + }); + }, [notificationPosition]); + React.useLayoutEffect(() => { const prevKeyList = prevKeyListRef.current; const prevNotificationPosition = prevNotificationPositionRef.current; @@ -211,7 +220,10 @@ const NotificationList: React.FC = (props) => { setNodeSize(strKey, node); }} style={{ - ...getNoticeStyle(notificationPosition.get(strKey)), + ...getNoticeStyle( + notificationPosition.get(strKey) ?? + notificationPositionCacheRef.current.get(strKey), + ), }} >
Date: Tue, 24 Mar 2026 15:19:52 +0800 Subject: [PATCH 12/76] refactor: extract notification list scroll hooks --- assets/geek.less | 9 +- docs/examples/NotificationList.tsx | 22 ++++ src/Notification.tsx | 18 +++ src/NotificationList.tsx | 180 +++++------------------------ src/hooks/useListPosition.ts | 31 +++++ src/hooks/useListScroll.ts | 116 +++++++++++++++++++ src/hooks/useSizes.ts | 37 ++++++ 7 files changed, 255 insertions(+), 158 deletions(-) create mode 100644 src/hooks/useListPosition.ts create mode 100644 src/hooks/useListScroll.ts create mode 100644 src/hooks/useSizes.ts diff --git a/assets/geek.less b/assets/geek.less index 916a6c8..43bb6f0 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -15,6 +15,7 @@ box-sizing: border-box; pointer-events: none; overflow: hidden; + overscroll-behavior: contain; } &-list-content { @@ -32,18 +33,14 @@ left: 0; box-sizing: border-box; width: 100%; - transform: translate3d(var(--notification-x, 0), var(--notification-y, 0), 0); - transition: transform @notificationMotionDuration @notificationMotionEase; - } - - &-list-item-motion { - width: 100%; } &-notice { pointer-events: auto; box-sizing: border-box; width: 100%; + transform: translate3d(var(--notification-x, 0), var(--notification-y, 0), 0); + transition: transform @notificationMotionDuration @notificationMotionEase; padding: 14px 16px; border: 2px solid #111; border-radius: 14px; diff --git a/docs/examples/NotificationList.tsx b/docs/examples/NotificationList.tsx index 9536e07..6e56dee 100644 --- a/docs/examples/NotificationList.tsx +++ b/docs/examples/NotificationList.tsx @@ -37,6 +37,22 @@ const Demo = () => { setConfigList((prevConfigList) => prevConfigList.slice(0, -1)); }, []); + const removeFirstConfig = React.useCallback(() => { + setConfigList((prevConfigList) => prevConfigList.slice(1)); + }, []); + + const removeMiddleConfig = React.useCallback(() => { + setConfigList((prevConfigList) => { + if (!prevConfigList.length) { + return prevConfigList; + } + + const middleIndex = Math.floor(prevConfigList.length / 2); + + return prevConfigList.filter((_, index) => index !== middleIndex); + }); + }, []); + return ( <>
@@ -46,6 +62,12 @@ const Demo = () => { + +
((props, actions, close, duration = 4.5, + offset, pauseOnHover = true, className, style, @@ -45,10 +50,17 @@ const Notification = React.forwardRef((props, // ========================= Close ========================== const onEventClose = useEvent(onClose); + const offsetRef = React.useRef(offset); + + if (offset) { + offsetRef.current = offset; + } // ======================== Duration ======================== const [onResume, onPause] = useNoticeTimer(duration, onEventClose, () => {}); + const mergedOffset = offset ?? offsetRef.current; + // ========================= Render ========================= return (
((props, className={clsx(className, classNames?.root)} style={{ ...styles?.root, + ...(mergedOffset + ? { + '--notification-x': `${mergedOffset.x}px`, + '--notification-y': `${mergedOffset.y}px`, + } + : null), ...style, }} onClick={onClick} diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 4005e7b..4d56b01 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -8,6 +8,7 @@ import Notification, { type NotificationStyles, } from './Notification'; import useListPosition from './hooks/useListPosition'; +import useListScroll from './hooks/useListScroll'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; @@ -32,14 +33,9 @@ export interface NotificationListProps { classNames?: NotificationClassNames; styles?: NotificationStyles; stack?: StackConfig; - maxCount?: number; motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); } -function clampScrollOffset(offset: number, maxScroll: number) { - return Math.min(0, Math.max(-maxScroll, offset)); -} - function assignRef(ref: React.Ref, value: T | null) { if (typeof ref === 'function') { ref(value); @@ -48,17 +44,6 @@ function assignRef(ref: React.Ref, value: T | null) { } } -function getNoticeStyle(nodePosition?: { x: number; y: number }): React.CSSProperties | undefined { - if (!nodePosition) { - return undefined; - } - - return { - '--notification-x': `${nodePosition.x}px`, - '--notification-y': `${nodePosition.y}px`, - } as React.CSSProperties; -} - const NotificationList: React.FC = (props) => { const { configList = [], @@ -66,18 +51,12 @@ const NotificationList: React.FC = (props) => { pauseOnHover, classNames, styles, - maxCount, motion, placement, } = props; // ========================== Data ========================== - const mergedConfigList = React.useMemo(() => { - const list = - typeof maxCount === 'number' && maxCount > 0 ? configList.slice(-maxCount) : configList; - - return list.slice().reverse(); - }, [configList, maxCount]); + const mergedConfigList = React.useMemo(() => configList.slice().reverse(), [configList]); const keys = React.useMemo( () => @@ -92,107 +71,15 @@ const NotificationList: React.FC = (props) => { // ========================= Motion ========================= const placementMotion = typeof motion === 'function' ? motion(placement) : motion; - // ========================= Scroll ========================= - const viewportRef = React.useRef(null); - const contentRef = React.useRef(null); - const prevKeyListRef = React.useRef(keyList); - const prevNotificationPositionRef = React.useRef>( - new Map(), - ); - const notificationPositionCacheRef = React.useRef>( - new Map(), - ); - const scrollOffsetRef = React.useRef(0); - const [scrollOffset, setScrollOffset] = React.useState(0); const [notificationPosition, setNodeSize] = useListPosition(mergedConfigList); - - const syncScrollOffset = React.useCallback((nextOffset: number) => { - const viewportHeight = viewportRef.current?.clientHeight ?? 0; - const measuredContentHeight = contentRef.current?.scrollHeight ?? 0; - const maxScroll = Math.max(measuredContentHeight - viewportHeight, 0); - const mergedOffset = clampScrollOffset(nextOffset, maxScroll); - - scrollOffsetRef.current = mergedOffset; - setScrollOffset((prev) => (prev === mergedOffset ? prev : mergedOffset)); - }, []); - - React.useLayoutEffect(() => { - notificationPosition.forEach((position, key) => { - notificationPositionCacheRef.current.set(key, position); - }); - }, [notificationPosition]); - - React.useLayoutEffect(() => { - const prevKeyList = prevKeyListRef.current; - const prevNotificationPosition = prevNotificationPositionRef.current; - - if (scrollOffsetRef.current < 0) { - const prependCount = prevKeyList.length - ? keyList.findIndex((key) => key === prevKeyList[0]) - : -1; - const removedCount = keyList.length ? prevKeyList.findIndex((key) => key === keyList[0]) : -1; - - if (prependCount > 0) { - const prependHeight = notificationPosition.get(prevKeyList[0])?.y ?? 0; - syncScrollOffset(scrollOffsetRef.current - prependHeight); - } else if (removedCount > 0) { - const removedHeight = keyList[0] ? (prevNotificationPosition.get(keyList[0])?.y ?? 0) : 0; - syncScrollOffset(scrollOffsetRef.current + removedHeight); - } else { - syncScrollOffset(scrollOffsetRef.current); - } - } else { - syncScrollOffset(scrollOffsetRef.current); - } - - prevKeyListRef.current = keyList; - prevNotificationPositionRef.current = new Map(notificationPosition); - }, [keyList, notificationPosition, syncScrollOffset]); - - React.useLayoutEffect(() => { - const viewportNode = viewportRef.current; - const contentNode = contentRef.current; - - if (!viewportNode || !contentNode || typeof ResizeObserver === 'undefined') { - return; - } - - const resizeObserver = new ResizeObserver(() => { - syncScrollOffset(scrollOffsetRef.current); - }); - - resizeObserver.observe(viewportNode); - resizeObserver.observe(contentNode); - - return () => { - resizeObserver.disconnect(); - }; - }, [syncScrollOffset]); - - const onWheel = React.useCallback( - (event: React.WheelEvent) => { - const viewportHeight = viewportRef.current?.clientHeight ?? 0; - const measuredContentHeight = contentRef.current?.scrollHeight ?? 0; - const maxScroll = Math.max(measuredContentHeight - viewportHeight, 0); - - if (!maxScroll) { - return; - } - - const nextOffset = clampScrollOffset(scrollOffsetRef.current - event.deltaY, maxScroll); - - if (nextOffset !== scrollOffsetRef.current) { - event.preventDefault(); - syncScrollOffset(nextOffset); - } - }, - [syncScrollOffset], + const { contentRef, onWheel, scrollOffset, viewportRef } = useListScroll( + keyList, + notificationPosition, ); // ========================= Render ========================= const listPrefixCls = `${prefixCls}-list`; const itemPrefixCls = `${listPrefixCls}-item`; - const motionPrefixCls = `${itemPrefixCls}-motion`; return (
= (props) => { return (
{ + assignRef(nodeRef, node); setNodeSize(strKey, node); }} - style={{ - ...getNoticeStyle( - notificationPosition.get(strKey) ?? - notificationPositionCacheRef.current.get(strKey), - ), - }} + style={style} > -
{ - assignRef(nodeRef, node); + - -
+ pauseOnHover={config.pauseOnHover ?? pauseOnHover} + />
); }} diff --git a/src/hooks/useListPosition.ts b/src/hooks/useListPosition.ts new file mode 100644 index 0000000..fe23446 --- /dev/null +++ b/src/hooks/useListPosition.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import useSizes from './useSizes'; + +export type NodePosition = { + x: number; + y: number; +}; + +export default function useListPosition(configList: { key: React.Key }[]) { + const [sizeMap, setNodeSize] = useSizes(); + + const notificationPosition = React.useMemo(() => { + let offsetY = 0; + const nextNotificationPosition = new Map(); + + configList.forEach((config) => { + const key = String(config.key); + const nodePosition = { + x: 0, + y: offsetY, + }; + + nextNotificationPosition.set(key, nodePosition); + offsetY += sizeMap[key]?.height ?? 0; + }); + + return nextNotificationPosition; + }, [configList, sizeMap]); + + return [notificationPosition, setNodeSize] as const; +} diff --git a/src/hooks/useListScroll.ts b/src/hooks/useListScroll.ts new file mode 100644 index 0000000..f2f1814 --- /dev/null +++ b/src/hooks/useListScroll.ts @@ -0,0 +1,116 @@ +import * as React from 'react'; +import type { NodePosition } from './useListPosition'; + +function clampScrollOffset(offset: number, maxScroll: number) { + return Math.min(0, Math.max(-maxScroll, offset)); +} + +function getViewportInnerHeight(node: HTMLDivElement | null) { + if (!node) { + return 0; + } + + const { paddingBottom, paddingTop } = window.getComputedStyle(node); + + return node.clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom); +} + +function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) { + const viewportHeight = getViewportInnerHeight(viewportNode); + const measuredContentHeight = contentNode?.scrollHeight ?? 0; + + return Math.max(measuredContentHeight - viewportHeight, 0); +} + +export default function useListScroll( + keyList: string[], + notificationPosition: Map, +) { + const viewportRef = React.useRef(null); + const contentRef = React.useRef(null); + const prevKeyListRef = React.useRef(keyList); + const prevNotificationPositionRef = React.useRef>(new Map()); + const scrollOffsetRef = React.useRef(0); + const [scrollOffset, setScrollOffset] = React.useState(0); + + const syncScrollOffset = React.useCallback((nextOffset: number) => { + const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); + const mergedOffset = clampScrollOffset(nextOffset, maxScroll); + + scrollOffsetRef.current = mergedOffset; + setScrollOffset((prev) => (prev === mergedOffset ? prev : mergedOffset)); + }, []); + + React.useLayoutEffect(() => { + const prevKeyList = prevKeyListRef.current; + const prevNotificationPosition = prevNotificationPositionRef.current; + + if (scrollOffsetRef.current < 0) { + const prependCount = prevKeyList.length + ? keyList.findIndex((key) => key === prevKeyList[0]) + : -1; + const removedCount = keyList.length ? prevKeyList.findIndex((key) => key === keyList[0]) : -1; + + if (prependCount > 0) { + const prependHeight = notificationPosition.get(prevKeyList[0])?.y ?? 0; + syncScrollOffset(scrollOffsetRef.current - prependHeight); + } else if (removedCount > 0) { + const removedHeight = keyList[0] ? (prevNotificationPosition.get(keyList[0])?.y ?? 0) : 0; + syncScrollOffset(scrollOffsetRef.current + removedHeight); + } else { + syncScrollOffset(scrollOffsetRef.current); + } + } else { + syncScrollOffset(scrollOffsetRef.current); + } + + prevKeyListRef.current = keyList; + prevNotificationPositionRef.current = new Map(notificationPosition); + }, [keyList, notificationPosition, syncScrollOffset]); + + React.useLayoutEffect(() => { + const viewportNode = viewportRef.current; + const contentNode = contentRef.current; + + if (!viewportNode || !contentNode || typeof ResizeObserver === 'undefined') { + return; + } + + const resizeObserver = new ResizeObserver(() => { + syncScrollOffset(scrollOffsetRef.current); + }); + + resizeObserver.observe(viewportNode); + resizeObserver.observe(contentNode); + + return () => { + resizeObserver.disconnect(); + }; + }, [syncScrollOffset]); + + const onWheel = React.useCallback( + (event: React.WheelEvent) => { + const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); + + if (!maxScroll) { + return; + } + + event.preventDefault(); + + const nextOffset = clampScrollOffset(scrollOffsetRef.current - event.deltaY, maxScroll); + + if (nextOffset !== scrollOffsetRef.current) { + syncScrollOffset(nextOffset); + } + }, + [syncScrollOffset], + ); + + return { + contentRef, + onWheel, + scrollOffset, + viewportRef, + }; +} diff --git a/src/hooks/useSizes.ts b/src/hooks/useSizes.ts new file mode 100644 index 0000000..57c361a --- /dev/null +++ b/src/hooks/useSizes.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; + +export type NodeSize = { + width: number; + height: number; +}; + +export type NodeSizeMap = Record; + +export default function useSizes() { + const [sizeMap, setSizeMap] = React.useState({}); + + const setNodeSize = React.useCallback((key: string, node: HTMLDivElement | null) => { + if (!node) { + return; + } + + const nextSize = { + width: node.offsetWidth, + height: node.offsetHeight, + }; + setSizeMap((prevSizeMap) => { + const prevSize = prevSizeMap[key]; + + if (prevSize && prevSize.width === nextSize.width && prevSize.height === nextSize.height) { + return prevSizeMap; + } + + return { + ...prevSizeMap, + [key]: nextSize, + }; + }); + }, []); + + return [sizeMap, setNodeSize] as const; +} From 04103749039737ad322b67fda20097f30f1c908e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 24 Mar 2026 15:45:38 +0800 Subject: [PATCH 13/76] refactor: nest useListPosition hooks --- src/hooks/{useListPosition.ts => useListPosition/index.ts} | 0 src/hooks/{ => useListPosition}/useSizes.ts | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/hooks/{useListPosition.ts => useListPosition/index.ts} (100%) rename src/hooks/{ => useListPosition}/useSizes.ts (100%) diff --git a/src/hooks/useListPosition.ts b/src/hooks/useListPosition/index.ts similarity index 100% rename from src/hooks/useListPosition.ts rename to src/hooks/useListPosition/index.ts diff --git a/src/hooks/useSizes.ts b/src/hooks/useListPosition/useSizes.ts similarity index 100% rename from src/hooks/useSizes.ts rename to src/hooks/useListPosition/useSizes.ts From 71219aca0ff5b3818af71ab240adc3c648285ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 24 Mar 2026 20:01:15 +0800 Subject: [PATCH 14/76] docs: add batch notification list demo action --- docs/examples/NotificationList.tsx | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/examples/NotificationList.tsx b/docs/examples/NotificationList.tsx index 6e56dee..9608930 100644 --- a/docs/examples/NotificationList.tsx +++ b/docs/examples/NotificationList.tsx @@ -33,6 +33,26 @@ const Demo = () => { ]); }, []); + const createFiveConfigs = React.useCallback(() => { + setConfigList((prevConfigList) => { + const startKey = keyRef.current; + keyRef.current += 5; + + return [ + ...prevConfigList, + ...Array.from({ length: 5 }, (_, index) => { + const key = startKey + index; + + return { + key, + duration: false, + content: `Config ${key + 1}`, + }; + }), + ]; + }); + }, []); + const removeLastConfig = React.useCallback(() => { setConfigList((prevConfigList) => prevConfigList.slice(0, -1)); }, []); @@ -59,6 +79,9 @@ const Demo = () => { + From 54b3e4843dbfa2a61586863fe367e74c4e0c8aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 25 Mar 2026 12:09:54 +0800 Subject: [PATCH 15/76] refactor: add useNotification hook --- assets/geek.less | 33 +++++++ docs/examples/hooks.tsx | 58 ++++++++---- src/Notification.tsx | 121 ++++++++++++++++++++++-- src/NotificationList.tsx | 100 ++++++++++++++++++-- src/Notifications.tsx | 162 +++++++++++++++++++++++++++++++ src/hooks/useNoticeTimer.ts | 29 ++++-- src/hooks/useNotification.tsx | 173 ++++++++++++++++++++++++++++++++++ src/index.ts | 4 +- 8 files changed, 635 insertions(+), 45 deletions(-) create mode 100644 src/Notifications.tsx create mode 100644 src/hooks/useNotification.tsx diff --git a/assets/geek.less b/assets/geek.less index 43bb6f0..87ffeda 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -49,6 +49,39 @@ color: #111; font-size: 14px; line-height: 1.5; + + &-content { + white-space: pre-wrap; + padding-right: 36px; + } + + &-close { + position: absolute; + top: 12px; + right: 12px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 2px solid #111; + border-radius: 999px; + background: #fff; + color: #111; + font-size: 14px; + line-height: 1; + cursor: pointer; + transition: + transform @notificationMotionDuration @notificationMotionEase, + box-shadow @notificationMotionDuration @notificationMotionEase, + background-color @notificationMotionDuration @notificationMotionEase; + + &:hover { + background: #f4f9ff; + box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.12); + transform: translate3d(-1px, -1px, 0); + } + } } } diff --git a/docs/examples/hooks.tsx b/docs/examples/hooks.tsx index a9d3094..e0ff048 100644 --- a/docs/examples/hooks.tsx +++ b/docs/examples/hooks.tsx @@ -1,18 +1,35 @@ /* eslint-disable no-console */ import React from 'react'; -import '../../assets/index.less'; +import type { CSSMotionProps } from '@rc-component/motion'; +import '../../assets/geek.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, + onLeaveStart: (ele) => { + const { offsetHeight } = ele; + return { height: offsetHeight }; + }, + onLeaveActive: () => ({ height: 0, opacity: 0, margin: 0 }), +}; const App = () => { - const [notice, contextHolder] = useNotification({ motion, closable: true }); + const [notice, contextHolder] = useNotification({ + motion, + closable: true, + prefixCls: 'notification', + }); return ( <> -
-
+
+
{/* Default */} {/* Not Close */}
-
+
{/* No Closable */}
-
-
- {/* Destroy All */} - +
+ {/* Destroy All */} + +
{contextHolder} diff --git a/src/Notification.tsx b/src/Notification.tsx index 94982d3..566b0f5 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -1,23 +1,37 @@ import * as React from 'react'; import { clsx } from 'clsx'; +import pickAttrs from '@rc-component/util/lib/pickAttrs'; import useNoticeTimer from './hooks/useNoticeTimer'; import { useEvent } from '@rc-component/util'; export interface NotificationClassNames { + wrapper?: string; root?: string; + content?: string; close?: string; + progress?: string; } export interface NotificationStyles { + wrapper?: React.CSSProperties; root?: React.CSSProperties; + content?: React.CSSProperties; close?: React.CSSProperties; + progress?: React.CSSProperties; } export interface NotificationProps { + prefixCls?: string; content?: React.ReactNode; actions?: React.ReactNode; close?: React.ReactNode; - duration?: number | false; + closable?: + | boolean + | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); + duration?: number | false | null; + showProgress?: boolean; + times?: number; + hovering?: boolean; offset?: { x: number; y: number; @@ -27,45 +41,91 @@ export interface NotificationProps { style?: React.CSSProperties; classNames?: NotificationClassNames; styles?: NotificationStyles; + props?: React.HTMLAttributes & Record; onClick?: React.MouseEventHandler; - /** Callback when notification is closed by timeout */ onClose?: () => void; + onCloseInternal?: VoidFunction; } const Notification = React.forwardRef((props, ref) => { const { + prefixCls = 'rc-notification', content, actions, close, + closable, duration = 4.5, + showProgress, + hovering: forcedHovering, offset, pauseOnHover = true, className, style, classNames, styles, + props: divProps, onClick, onClose, + onCloseInternal, } = props; + const [hovering, setHovering] = React.useState(false); + const [percent, setPercent] = React.useState(0); + const noticePrefixCls = `${prefixCls}-notice`; // ========================= Close ========================== const onEventClose = useEvent(onClose); + const onEventCloseInternal = useEvent(onCloseInternal); const offsetRef = React.useRef(offset); + const closableObj = React.useMemo(() => { + if (typeof closable === 'object' && closable !== null) { + return closable; + } + + return {}; + }, [closable]); + const closeContent = close === undefined ? (closableObj.closeIcon ?? 'x') : close; + const mergedClosable = close !== undefined ? close !== null : !!closable; + const ariaProps = pickAttrs(closableObj, true); if (offset) { offsetRef.current = offset; } // ======================== Duration ======================== - const [onResume, onPause] = useNoticeTimer(duration, onEventClose, () => {}); + const [onResume, onPause] = useNoticeTimer( + duration, + () => { + closableObj.onClose?.(); + onEventClose(); + onEventCloseInternal(); + }, + setPercent, + !!showProgress, + ); const mergedOffset = offset ?? offsetRef.current; + const validPercent = 100 - Math.min(Math.max(percent * 100, 0), 100); + + React.useEffect(() => { + if (!pauseOnHover) { + return; + } + + if (forcedHovering) { + onPause(); + } else if (!hovering) { + onResume(); + } + }, [forcedHovering, hovering, onPause, onResume, pauseOnHover]); // ========================= Render ========================= return (
((props, ...style, }} onClick={onClick} - onMouseEnter={pauseOnHover ? onPause : undefined} - onMouseLeave={pauseOnHover ? onResume : undefined} + onMouseEnter={(event) => { + setHovering(true); + if (pauseOnHover) { + onPause(); + } + divProps?.onMouseEnter?.(event); + }} + onMouseLeave={(event) => { + setHovering(false); + if (pauseOnHover && !forcedHovering) { + onResume(); + } + divProps?.onMouseLeave?.(event); + }} > - {content} - {close && ( +
+ {content} +
+ + {mergedClosable && ( )} + + {showProgress && typeof duration === 'number' && duration > 0 && ( + + {validPercent}% + + )} + {actions &&
{actions}
}
); diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 4d56b01..c1e9735 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -2,6 +2,8 @@ import { CSSMotionList } from '@rc-component/motion'; import type { CSSMotionProps } from '@rc-component/motion'; import { clsx } from 'clsx'; import * as React from 'react'; +import { NotificationContext } from './legacy/NotificationProvider'; +import useStack from './legacy/hooks/useStack'; import Notification, { type NotificationClassNames, type NotificationProps, @@ -22,18 +24,23 @@ export type StackConfig = export interface NotificationListConfig extends NotificationProps { key: React.Key; + placement?: Placement; + times?: number; } export interface NotificationListProps { configList?: NotificationListConfig[]; prefixCls?: string; - getContainer?: () => HTMLElement | ShadowRoot; placement?: Placement; pauseOnHover?: boolean; classNames?: NotificationClassNames; styles?: NotificationStyles; stack?: StackConfig; motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); + className?: string; + style?: React.CSSProperties; + onNoticeClose?: (key: React.Key) => void; + onAllRemoved?: (placement: Placement) => void; } function assignRef(ref: React.Ref, value: T | null) { @@ -51,9 +58,15 @@ const NotificationList: React.FC = (props) => { pauseOnHover, classNames, styles, + stack: stackConfig, motion, placement, + className, + style, + onNoticeClose, + onAllRemoved, } = props; + const { classNames: contextClassNames } = React.useContext(NotificationContext); // ========================== Data ========================== const mergedConfigList = React.useMemo(() => configList.slice().reverse(), [configList]); @@ -70,6 +83,9 @@ const NotificationList: React.FC = (props) => { // ========================= Motion ========================= const placementMotion = typeof motion === 'function' ? motion(placement) : motion; + const [stack, { threshold }] = useStack(stackConfig); + const [hoverKeys, setHoverKeys] = React.useState([]); + const expanded = stack && (hoverKeys.length > 0 || keys.length <= threshold); const [notificationPosition, setNodeSize] = useListPosition(mergedConfigList); const { contentRef, onWheel, scrollOffset, viewportRef } = useListScroll( @@ -77,15 +93,40 @@ const NotificationList: React.FC = (props) => { notificationPosition, ); + React.useEffect(() => { + if (stack && hoverKeys.length > 1) { + setHoverKeys((originKeys) => { + const nextKeys = originKeys.filter((key) => + keyList.some((existingKey) => existingKey === key), + ); + + return nextKeys.length === originKeys.length ? originKeys : nextKeys; + }); + } + }, [hoverKeys, keyList, stack]); + // ========================= Render ========================= const listPrefixCls = `${prefixCls}-list`; const itemPrefixCls = `${listPrefixCls}-item`; + const noticeWrapperCls = `${prefixCls}-notice-wrapper`; return (
= (props) => { }} ref={contentRef} > - + { + if (placement) { + onAllRemoved?.(placement); + } + }} + > {({ config, className, style }, nodeRef) => { - const { key, ...notificationConfig } = config; + const { key, placement: _placement, ...notificationConfig } = config; const strKey = String(key); return (
{ assignRef(nodeRef, node); setNodeSize(strKey, node); }} - style={style} + style={{ + ...style, + ...styles?.wrapper, + ...config.styles?.wrapper, + }} + onMouseEnter={() => + setHoverKeys((originKeys) => + originKeys.includes(strKey) ? originKeys : [...originKeys, strKey], + ) + } + onMouseLeave={() => + setHoverKeys((originKeys) => originKeys.filter((key) => key !== strKey)) + } > 0} pauseOnHover={config.pauseOnHover ?? pauseOnHover} + onCloseInternal={() => { + onNoticeClose?.(key); + }} />
); @@ -140,3 +225,4 @@ const NotificationList: React.FC = (props) => { }; export default NotificationList; +export type { NotificationClassNames, NotificationStyles } from './Notification'; diff --git a/src/Notifications.tsx b/src/Notifications.tsx new file mode 100644 index 0000000..0a3f8ec --- /dev/null +++ b/src/Notifications.tsx @@ -0,0 +1,162 @@ +import * as React from 'react'; +import type { ReactElement } from 'react'; +import { createPortal } from 'react-dom'; +import type { CSSMotionProps } from '@rc-component/motion'; +import NotificationList, { + type NotificationListConfig, + type Placement, + type StackConfig, +} from './NotificationList'; + +export interface NotificationsProps { + prefixCls?: string; + motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); + container?: HTMLElement | ShadowRoot; + maxCount?: number; + className?: (placement: Placement) => string; + style?: (placement: Placement) => React.CSSProperties; + onAllRemoved?: VoidFunction; + stack?: StackConfig; + renderNotifications?: ( + node: ReactElement, + info: { prefixCls: string; key: React.Key }, + ) => ReactElement; +} + +export interface NotificationsRef { + open: (config: NotificationListConfig) => void; + close: (key: React.Key) => void; + destroy: () => void; +} + +type Placements = Partial>; + +const Notifications = React.forwardRef((props, ref) => { + const { + prefixCls = 'rc-notification', + container, + motion, + maxCount, + className, + style, + onAllRemoved, + stack, + renderNotifications, + } = props; + const [configList, setConfigList] = React.useState([]); + + React.useImperativeHandle(ref, () => ({ + open: (config) => { + setConfigList((list) => { + let clone = [...list]; + + const index = clone.findIndex((item) => item.key === config.key); + const innerConfig: NotificationListConfig = { ...config }; + + if (index >= 0) { + innerConfig.times = (list[index]?.times ?? 0) + 1; + clone[index] = innerConfig; + } else { + innerConfig.times = 0; + clone.push(innerConfig); + } + + if (maxCount && maxCount > 0 && clone.length > maxCount) { + clone = clone.slice(-maxCount); + } + + return clone; + }); + }, + close: (key) => { + setConfigList((list) => list.filter((item) => item.key !== key)); + }, + destroy: () => { + setConfigList([]); + }, + })); + + const [placements, setPlacements] = React.useState({}); + + React.useEffect(() => { + const nextPlacements: Placements = {}; + + configList.forEach((config) => { + const placement = config.placement ?? 'topRight'; + nextPlacements[placement] = nextPlacements[placement] || []; + nextPlacements[placement].push(config); + }); + + Object.keys(placements).forEach((placement) => { + nextPlacements[placement as Placement] = nextPlacements[placement as Placement] || []; + }); + + setPlacements(nextPlacements); + }, [configList]); + + const onAllNoticeRemoved = React.useCallback((placement: Placement) => { + setPlacements((originPlacements) => { + const clone = { + ...originPlacements, + }; + + if (!(clone[placement] || []).length) { + delete clone[placement]; + } + + return clone; + }); + }, []); + + const emptyRef = React.useRef(false); + React.useEffect(() => { + if (Object.keys(placements).length > 0) { + emptyRef.current = true; + } else if (emptyRef.current) { + onAllRemoved?.(); + emptyRef.current = false; + } + }, [placements, onAllRemoved]); + + if (!container) { + return null; + } + + const placementList = Object.keys(placements) as Placement[]; + + return createPortal( + <> + {placementList.map((placement) => { + const list = ( + { + setConfigList((list) => list.filter((item) => item.key !== key)); + }} + onAllRemoved={onAllNoticeRemoved} + /> + ); + + return renderNotifications + ? React.cloneElement(renderNotifications(list, { prefixCls, key: placement }), { + key: placement, + }) + : list; + })} + , + container, + ); +}); + +if (process.env.NODE_ENV !== 'production') { + Notifications.displayName = 'Notifications'; +} + +export default Notifications; diff --git a/src/hooks/useNoticeTimer.ts b/src/hooks/useNoticeTimer.ts index e905def..000e027 100644 --- a/src/hooks/useNoticeTimer.ts +++ b/src/hooks/useNoticeTimer.ts @@ -3,9 +3,10 @@ import raf from '@rc-component/util/es/raf'; import useEvent from '@rc-component/util/es/hooks/useEvent'; export default function useNoticeTimer( - duration: number | false, + duration: number | false | null, onClose: VoidFunction, onUpdate: (ptg: number) => void, + trackProgress = true, ) { const mergedDuration = typeof duration === 'number' ? duration : 0; const durationMs = Math.max(mergedDuration, 0) * 1000; @@ -23,18 +24,18 @@ export default function useNoticeTimer( passTimeRef.current += passedTime; } - function onPause() { + const onPause = React.useCallback(() => { syncPassTime(); setWalking(false); - } + }, []); - function onResume() { + const onResume = React.useCallback(() => { if (durationMs > 0) { setWalking(true); } else { onEventUpdate(0); } - } + }, [durationMs, onEventUpdate]); React.useEffect(() => { if (durationMs <= 0) { @@ -52,19 +53,30 @@ export default function useNoticeTimer( } if (passTimeRef.current >= durationMs) { + onEventUpdate(1); onEventClose(); return; } + const timeout = window.setTimeout(() => { + passTimeRef.current = durationMs; + onEventUpdate(1); + onEventClose(); + }, durationMs - passTimeRef.current); + + if (!trackProgress) { + return () => { + window.clearTimeout(timeout); + }; + } + let rafId: number | null = null; function step() { syncPassTime(); onEventUpdate(Math.min(passTimeRef.current / durationMs, 1)); - if (passTimeRef.current >= durationMs) { - onEventClose(); - } else { + if (passTimeRef.current < durationMs) { rafId = raf(step); } } @@ -73,6 +85,7 @@ export default function useNoticeTimer( rafId = raf(step); return () => { + window.clearTimeout(timeout); raf.cancel(rafId); }; }, [durationMs, walking]); diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx new file mode 100644 index 0000000..540902b --- /dev/null +++ b/src/hooks/useNotification.tsx @@ -0,0 +1,173 @@ +import type { CSSMotionProps } from '@rc-component/motion'; +import { useEvent } from '@rc-component/util'; +import * as React from 'react'; +import Notifications, { type NotificationsProps, type NotificationsRef } from '../Notifications'; +import type { + NotificationClassNames, + NotificationListConfig, + NotificationStyles, +} from '../NotificationList'; +import type { Placement, StackConfig } from '../NotificationList'; + +const defaultGetContainer = () => document.body; + +type OptionalConfig = Partial; + +export interface NotificationConfig { + prefixCls?: string; + getContainer?: () => HTMLElement | ShadowRoot; + motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); + placement?: Placement; + closable?: NotificationListConfig['closable']; + duration?: number | false | null; + showProgress?: boolean; + pauseOnHover?: boolean; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + maxCount?: number; + className?: (placement: Placement) => string; + style?: (placement: Placement) => React.CSSProperties; + onAllRemoved?: VoidFunction; + stack?: StackConfig; + renderNotifications?: NotificationsProps['renderNotifications']; +} + +export interface NotificationAPI { + open: (config: OptionalConfig) => void; + close: (key: React.Key) => void; + destroy: () => void; +} + +interface OpenTask { + type: 'open'; + config: NotificationListConfig; +} + +interface CloseTask { + type: 'close'; + key: React.Key; +} + +interface DestroyTask { + type: 'destroy'; +} + +type Task = OpenTask | CloseTask | DestroyTask; + +let uniqueKey = 0; + +function mergeConfig(...objList: Partial[]): T { + const clone = {} as T; + + objList.forEach((obj) => { + if (!obj) { + return; + } + + Object.keys(obj).forEach((key) => { + const value = obj[key as keyof T]; + + if (value !== undefined) { + clone[key as keyof T] = value; + } + }); + }); + + return clone; +} + +export default function useNotification( + rootConfig: NotificationConfig = {}, +): [NotificationAPI, React.ReactElement] { + const { + getContainer = defaultGetContainer, + motion, + prefixCls, + maxCount, + className, + style, + onAllRemoved, + stack, + renderNotifications, + ...shareConfig + } = rootConfig; + + const [container, setContainer] = React.useState(); + const notificationsRef = React.useRef(null); + const contextHolder = ( + + ); + + const [taskQueue, setTaskQueue] = React.useState([]); + + const open = useEvent((config) => { + const mergedConfig = mergeConfig(shareConfig, config); + + if (mergedConfig.key === null || mergedConfig.key === undefined) { + mergedConfig.key = `rc-notification-${uniqueKey}`; + uniqueKey += 1; + } + + setTaskQueue((queue) => [...queue, { type: 'open', config: mergedConfig }]); + }); + + const api = React.useMemo( + () => ({ + open, + close: (key) => { + setTaskQueue((queue) => [...queue, { type: 'close', key }]); + }, + destroy: () => { + setTaskQueue((queue) => [...queue, { type: 'destroy' }]); + }, + }), + [open], + ); + + React.useEffect(() => { + setContainer(getContainer()); + }); + + React.useEffect(() => { + if (notificationsRef.current && taskQueue.length) { + taskQueue.forEach((task) => { + switch (task.type) { + case 'open': + notificationsRef.current?.open(task.config); + break; + case 'close': + notificationsRef.current?.close(task.key); + break; + case 'destroy': + notificationsRef.current?.destroy(); + break; + } + }); + + let originTaskQueue: Task[]; + let targetTaskQueue: Task[]; + + setTaskQueue((originQueue) => { + if (originTaskQueue !== originQueue || !targetTaskQueue) { + originTaskQueue = originQueue; + targetTaskQueue = originQueue.filter((task) => !taskQueue.includes(task)); + } + + return targetTaskQueue; + }); + } + }, [taskQueue]); + + return [api, contextHolder]; +} diff --git a/src/index.ts b/src/index.ts index 35b7e54..eb17410 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -import useNotification from './legacy/hooks/useNotification'; +import useNotification from './hooks/useNotification'; import Notice from './legacy/Notice'; -import type { NotificationAPI, NotificationConfig } from './legacy/hooks/useNotification'; +import type { NotificationAPI, NotificationConfig } from './hooks/useNotification'; import NotificationProvider from './legacy/NotificationProvider'; export { useNotification, Notice, NotificationProvider }; From 2a0324608cbddccb1bb75a3eba02cb051b47ffac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 25 Mar 2026 12:11:16 +0800 Subject: [PATCH 16/76] refactor: require React 18 for hooks --- package.json | 4 ++-- src/hooks/useNotification.tsx | 10 ++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 737bc92..d14071f 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,8 @@ "vitest": "^0.34.2" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" }, "lint-staged": { "**/*.{js,jsx,tsx,ts,md,json}": [ diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx index 540902b..11fe618 100644 --- a/src/hooks/useNotification.tsx +++ b/src/hooks/useNotification.tsx @@ -155,16 +155,10 @@ export default function useNotification( } }); - let originTaskQueue: Task[]; - let targetTaskQueue: Task[]; - setTaskQueue((originQueue) => { - if (originTaskQueue !== originQueue || !targetTaskQueue) { - originTaskQueue = originQueue; - targetTaskQueue = originQueue.filter((task) => !taskQueue.includes(task)); - } + const targetTaskQueue = originQueue.filter((task) => !taskQueue.includes(task)); - return targetTaskQueue; + return targetTaskQueue.length === originQueue.length ? originQueue : targetTaskQueue; }); } }, [taskQueue]); From 95968fef190f6f08e40ea7d7c190dbfd990123fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 25 Mar 2026 15:18:41 +0800 Subject: [PATCH 17/76] fix: forward notification hook defaults --- src/Notifications.tsx | 11 +++++++ src/hooks/useNotification.tsx | 47 ++++++++++++++++++++++------ src/legacy/hooks/useNotification.tsx | 23 +++----------- tests/hooks.test.tsx | 39 +++++++++++++++++++++++ tests/index.test.tsx | 2 +- 5 files changed, 93 insertions(+), 29 deletions(-) diff --git a/src/Notifications.tsx b/src/Notifications.tsx index 0a3f8ec..47a370d 100644 --- a/src/Notifications.tsx +++ b/src/Notifications.tsx @@ -3,7 +3,9 @@ import type { ReactElement } from 'react'; import { createPortal } from 'react-dom'; import type { CSSMotionProps } from '@rc-component/motion'; import NotificationList, { + type NotificationClassNames, type NotificationListConfig, + type NotificationStyles, type Placement, type StackConfig, } from './NotificationList'; @@ -13,6 +15,9 @@ export interface NotificationsProps { motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); container?: HTMLElement | ShadowRoot; maxCount?: number; + pauseOnHover?: boolean; + classNames?: NotificationClassNames; + styles?: NotificationStyles; className?: (placement: Placement) => string; style?: (placement: Placement) => React.CSSProperties; onAllRemoved?: VoidFunction; @@ -37,6 +42,9 @@ const Notifications = React.forwardRef((pr container, motion, maxCount, + pauseOnHover, + classNames, + styles, className, style, onAllRemoved, @@ -133,6 +141,9 @@ const Notifications = React.forwardRef((pr configList={placements[placement]} placement={placement} prefixCls={prefixCls} + pauseOnHover={pauseOnHover} + classNames={classNames} + styles={styles} className={className?.(placement)} style={style?.(placement)} motion={motion} diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx index 11fe618..99cab1a 100644 --- a/src/hooks/useNotification.tsx +++ b/src/hooks/useNotification.tsx @@ -11,24 +11,32 @@ import type { Placement, StackConfig } from '../NotificationList'; const defaultGetContainer = () => document.body; +// ========================= Types ========================== type OptionalConfig = Partial; +type SharedConfig = Pick; export interface NotificationConfig { + // Style prefixCls?: string; + className?: (placement: Placement) => string; + style?: (placement: Placement) => React.CSSProperties; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + + // UI + placement?: Placement; getContainer?: () => HTMLElement | ShadowRoot; motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - placement?: Placement; + + // Behavior closable?: NotificationListConfig['closable']; duration?: number | false | null; - showProgress?: boolean; pauseOnHover?: boolean; - classNames?: NotificationClassNames; - styles?: NotificationStyles; maxCount?: number; - className?: (placement: Placement) => string; - style?: (placement: Placement) => React.CSSProperties; - onAllRemoved?: VoidFunction; stack?: StackConfig; + + // Function + onAllRemoved?: VoidFunction; renderNotifications?: NotificationsProps['renderNotifications']; } @@ -54,6 +62,7 @@ interface DestroyTask { type Task = OpenTask | CloseTask | DestroyTask; +// ======================== Helper ========================== let uniqueKey = 0; function mergeConfig(...objList: Partial[]): T { @@ -79,21 +88,35 @@ function mergeConfig(...objList: Partial[]): T { export default function useNotification( rootConfig: NotificationConfig = {}, ): [NotificationAPI, React.ReactElement] { + // ========================= Config ========================= const { getContainer = defaultGetContainer, motion, prefixCls, + placement, + closable, + duration, + pauseOnHover, + classNames, + styles, maxCount, className, style, onAllRemoved, stack, renderNotifications, - ...shareConfig } = rootConfig; + const shareConfig: SharedConfig = { + placement, + closable, + duration, + }; + // ========================= Holder ========================= const [container, setContainer] = React.useState(); const notificationsRef = React.useRef(null); + const [taskQueue, setTaskQueue] = React.useState([]); + const contextHolder = ( ); - const [taskQueue, setTaskQueue] = React.useState([]); - + // ========================== API ========================== const open = useEvent((config) => { const mergedConfig = mergeConfig(shareConfig, config); @@ -135,6 +160,7 @@ export default function useNotification( [open], ); + // ======================== Effect ========================= React.useEffect(() => { setContainer(getContainer()); }); @@ -163,5 +189,6 @@ export default function useNotification( } }, [taskQueue]); + // ======================== Return ========================= return [api, contextHolder]; } diff --git a/src/legacy/hooks/useNotification.tsx b/src/legacy/hooks/useNotification.tsx index eb51b8b..b5aed00 100644 --- a/src/legacy/hooks/useNotification.tsx +++ b/src/legacy/hooks/useNotification.tsx @@ -135,15 +135,14 @@ export default function useNotification( ); // ======================= Container ====================== - // React 18 should all in effect that we will check container in each render - // Which means getContainer should be stable. + // `getContainer` should be stable. React.useEffect(() => { setContainer(getContainer()); }); // ======================== Effect ======================== React.useEffect(() => { - // Flush task when node ready + // Flush queued tasks once the holder is ready. if (notificationsRef.current && taskQueue.length) { taskQueue.forEach((task) => { switch (task.type) { @@ -163,23 +162,11 @@ export default function useNotification( // https://github.com/ant-design/ant-design/issues/52590 // React `startTransition` will run once `useEffect` but many times `setState`, - // So `setTaskQueue` with filtered array will cause infinite loop. - // We cache the first match queue instead. - let oriTaskQueue: Task[]; - let tgtTaskQueue: Task[]; - - // React 17 will mix order of effect & setState in async - // - open: setState[0] - // - effect[0] - // - open: setState[1] - // - effect setState([]) * here will clean up [0, 1] in React 17 + // so we only publish a new queue when something was actually removed. setTaskQueue((oriQueue) => { - if (oriTaskQueue !== oriQueue || !tgtTaskQueue) { - oriTaskQueue = oriQueue; - tgtTaskQueue = oriQueue.filter((task) => !taskQueue.includes(task)); - } + const tgtTaskQueue = oriQueue.filter((task) => !taskQueue.includes(task)); - return tgtTaskQueue; + return tgtTaskQueue.length === oriQueue.length ? oriQueue : tgtTaskQueue; }); } }, [taskQueue]); diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 78c031d..535a5b6 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -180,4 +180,43 @@ describe('Notification.Hooks', () => { expect(document.querySelector('.rc-notification')).toHaveClass('banana'); expect(document.querySelector('.custom-notice')).toHaveClass('apple'); }); + + it('support root classNames defaults', () => { + const { instance } = renderDemo({ + classNames: { + wrapper: 'hook-wrapper', + close: 'hook-close', + }, + }); + + act(() => { + instance.open({ + content:
, + duration: 0, + closable: true, + classNames: { + root: 'notice-root', + }, + }); + }); + + expect(document.querySelector('.rc-notification-notice-wrapper')).toHaveClass('hook-wrapper'); + expect(document.querySelector('.rc-notification-notice')).toHaveClass('notice-root'); + expect(document.querySelector('.rc-notification-notice-close')).toHaveClass('hook-close'); + }); + + it('support root placement defaults', () => { + const { instance } = renderDemo({ + placement: 'bottomLeft', + }); + + act(() => { + instance.open({ + content:
, + duration: 0, + }); + }); + + expect(document.querySelector('.rc-notification')).toHaveClass('rc-notification-bottomLeft'); + }); }); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 6b47858..f925131 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -923,12 +923,12 @@ describe('Notification.Basic', () => { it('show with progress', () => { const { instance } = renderDemo({ duration: 1, - showProgress: true, }); act(() => { instance.open({ content:

1

, + showProgress: true, }); }); From c65ca1e0c6af4c8ab5c58953c37bd6e65d02f4a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 30 Mar 2026 15:53:31 +0800 Subject: [PATCH 18/76] refactor: reorganize Notifications props and simplify useNotification types Co-Authored-By: Claude Opus 4.6 --- docs/examples/NotificationList.tsx | 43 +++++++++++---------- src/NotificationList.tsx | 60 +++++++++++++----------------- src/Notifications.tsx | 31 +++++++++++---- src/hooks/useListPosition/index.ts | 7 ++-- src/hooks/useNotification.tsx | 34 +++++------------ src/interface.ts | 4 ++ tests/stack.test.tsx | 45 +++++++++++++++++++--- 7 files changed, 130 insertions(+), 94 deletions(-) create mode 100644 src/interface.ts diff --git a/docs/examples/NotificationList.tsx b/docs/examples/NotificationList.tsx index 9608930..6a54ea1 100644 --- a/docs/examples/NotificationList.tsx +++ b/docs/examples/NotificationList.tsx @@ -17,21 +17,23 @@ const motion: CSSMotionProps = { const Demo = () => { const [configList, setConfigList] = React.useState([]); + const [stack, setStack] = React.useState(false); const keyRef = React.useRef(0); + const createNotification = React.useCallback( + (key: number): NotificationListConfig => ({ + key, + duration: false, + content: `Config ${key + 1}`, + }), + [], + ); const createConfig = React.useCallback(() => { const key = keyRef.current; keyRef.current += 1; - setConfigList((prevConfigList) => [ - ...prevConfigList, - { - key, - duration: false, - content: `Config ${key + 1}`, - }, - ]); - }, []); + setConfigList((prevConfigList) => [...prevConfigList, createNotification(key)]); + }, [createNotification]); const createFiveConfigs = React.useCallback(() => { setConfigList((prevConfigList) => { @@ -40,18 +42,10 @@ const Demo = () => { return [ ...prevConfigList, - ...Array.from({ length: 5 }, (_, index) => { - const key = startKey + index; - - return { - key, - duration: false, - content: `Config ${key + 1}`, - }; - }), + ...Array.from({ length: 5 }, (_, index) => createNotification(startKey + index)), ]; }); - }, []); + }, [createNotification]); const removeLastConfig = React.useCallback(() => { setConfigList((prevConfigList) => prevConfigList.slice(0, -1)); @@ -91,6 +85,16 @@ const Demo = () => { +
{ classNames={{ root: 'notification-notice' }} motion={motion} placement="topRight" + stack={stack ? { threshold: 3, offset: 20 } : undefined} /> ); diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index c1e9735..2e4ce6d 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -2,6 +2,7 @@ import { CSSMotionList } from '@rc-component/motion'; import type { CSSMotionProps } from '@rc-component/motion'; import { clsx } from 'clsx'; import * as React from 'react'; +import type { StackConfig } from './interface'; import { NotificationContext } from './legacy/NotificationProvider'; import useStack from './legacy/hooks/useStack'; import Notification, { @@ -13,14 +14,7 @@ import useListPosition from './hooks/useListPosition'; import useListScroll from './hooks/useListScroll'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; - -export type StackConfig = - | boolean - | { - threshold?: number; - offset?: number; - gap?: number; - }; +export type { StackConfig } from './interface'; export interface NotificationListConfig extends NotificationProps { key: React.Key; @@ -35,7 +29,7 @@ export interface NotificationListProps { pauseOnHover?: boolean; classNames?: NotificationClassNames; styles?: NotificationStyles; - stack?: StackConfig; + stack?: boolean | StackConfig; motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); className?: string; style?: React.CSSProperties; @@ -83,28 +77,26 @@ const NotificationList: React.FC = (props) => { // ========================= Motion ========================= const placementMotion = typeof motion === 'function' ? motion(placement) : motion; - const [stack, { threshold }] = useStack(stackConfig); - const [hoverKeys, setHoverKeys] = React.useState([]); - const expanded = stack && (hoverKeys.length > 0 || keys.length <= threshold); + const [stackEnabled, { offset, threshold }] = useStack(stackConfig); + const [listHovering, setListHovering] = React.useState(false); + const expanded = stackEnabled && (listHovering || keys.length <= threshold); + const stackPosition = React.useMemo(() => { + if (!stackEnabled || expanded) { + return undefined; + } + + return { + offset, + threshold, + }; + }, [expanded, offset, stackEnabled, threshold]); - const [notificationPosition, setNodeSize] = useListPosition(mergedConfigList); + const [notificationPosition, setNodeSize] = useListPosition(mergedConfigList, stackPosition); const { contentRef, onWheel, scrollOffset, viewportRef } = useListScroll( keyList, notificationPosition, ); - React.useEffect(() => { - if (stack && hoverKeys.length > 1) { - setHoverKeys((originKeys) => { - const nextKeys = originKeys.filter((key) => - keyList.some((existingKey) => existingKey === key), - ); - - return nextKeys.length === originKeys.length ? originKeys : nextKeys; - }); - } - }, [hoverKeys, keyList, stack]); - // ========================= Render ========================= const listPrefixCls = `${prefixCls}-list`; const itemPrefixCls = `${listPrefixCls}-item`; @@ -120,11 +112,17 @@ const NotificationList: React.FC = (props) => { contextClassNames?.list, className, { - [`${prefixCls}-stack`]: stack, + [`${prefixCls}-stack`]: stackEnabled, [`${prefixCls}-stack-expanded`]: expanded, }, )} onWheel={onWheel} + onMouseEnter={() => { + setListHovering(true); + }} + onMouseLeave={() => { + setListHovering(false); + }} ref={viewportRef} style={style} > @@ -169,14 +167,6 @@ const NotificationList: React.FC = (props) => { ...styles?.wrapper, ...config.styles?.wrapper, }} - onMouseEnter={() => - setHoverKeys((originKeys) => - originKeys.includes(strKey) ? originKeys : [...originKeys, strKey], - ) - } - onMouseLeave={() => - setHoverKeys((originKeys) => originKeys.filter((key) => key !== strKey)) - } > = (props) => { ...config.styles?.progress, }, }} - hovering={stack && hoverKeys.length > 0} + hovering={stackEnabled && listHovering} pauseOnHover={config.pauseOnHover ?? pauseOnHover} onCloseInternal={() => { onNoticeClose?.(key); diff --git a/src/Notifications.tsx b/src/Notifications.tsx index 47a370d..35ed4ed 100644 --- a/src/Notifications.tsx +++ b/src/Notifications.tsx @@ -11,17 +11,24 @@ import NotificationList, { } from './NotificationList'; export interface NotificationsProps { + // Style prefixCls?: string; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - container?: HTMLElement | ShadowRoot; - maxCount?: number; - pauseOnHover?: boolean; classNames?: NotificationClassNames; styles?: NotificationStyles; className?: (placement: Placement) => string; style?: (placement: Placement) => React.CSSProperties; + + // UI + container?: HTMLElement | ShadowRoot; + motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); + + // Behavior + maxCount?: number; + pauseOnHover?: boolean; + stack?: boolean | StackConfig; + + // Function onAllRemoved?: VoidFunction; - stack?: StackConfig; renderNotifications?: ( node: ReactElement, info: { prefixCls: string; key: React.Key }, @@ -34,9 +41,11 @@ export interface NotificationsRef { destroy: () => void; } +// ========================= Types ========================== type Placements = Partial>; const Notifications = React.forwardRef((props, ref) => { + // ========================= Props ========================== const { prefixCls = 'rc-notification', container, @@ -51,8 +60,13 @@ const Notifications = React.forwardRef((pr stack, renderNotifications, } = props; + + // ========================= State ========================== const [configList, setConfigList] = React.useState([]); + const [placements, setPlacements] = React.useState({}); + const emptyRef = React.useRef(false); + // ========================== Ref =========================== React.useImperativeHandle(ref, () => ({ open: (config) => { setConfigList((list) => { @@ -84,8 +98,7 @@ const Notifications = React.forwardRef((pr }, })); - const [placements, setPlacements] = React.useState({}); - + // ======================== Effect ========================= React.useEffect(() => { const nextPlacements: Placements = {}; @@ -102,6 +115,7 @@ const Notifications = React.forwardRef((pr setPlacements(nextPlacements); }, [configList]); + // ======================== Callback ======================= const onAllNoticeRemoved = React.useCallback((placement: Placement) => { setPlacements((originPlacements) => { const clone = { @@ -116,7 +130,7 @@ const Notifications = React.forwardRef((pr }); }, []); - const emptyRef = React.useRef(false); + // ======================== Effect ========================= React.useEffect(() => { if (Object.keys(placements).length > 0) { emptyRef.current = true; @@ -126,6 +140,7 @@ const Notifications = React.forwardRef((pr } }, [placements, onAllRemoved]); + // ======================== Render ========================= if (!container) { return null; } diff --git a/src/hooks/useListPosition/index.ts b/src/hooks/useListPosition/index.ts index fe23446..4ac4d81 100644 --- a/src/hooks/useListPosition/index.ts +++ b/src/hooks/useListPosition/index.ts @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { StackConfig } from '../../interface'; import useSizes from './useSizes'; export type NodePosition = { @@ -6,7 +7,7 @@ export type NodePosition = { y: number; }; -export default function useListPosition(configList: { key: React.Key }[]) { +export default function useListPosition(configList: { key: React.Key }[], stack?: StackConfig) { const [sizeMap, setNodeSize] = useSizes(); const notificationPosition = React.useMemo(() => { @@ -17,7 +18,7 @@ export default function useListPosition(configList: { key: React.Key }[]) { const key = String(config.key); const nodePosition = { x: 0, - y: offsetY, + y: stack ? offsetY + (stack.offset ?? 0) : offsetY, }; nextNotificationPosition.set(key, nodePosition); @@ -25,7 +26,7 @@ export default function useListPosition(configList: { key: React.Key }[]) { }); return nextNotificationPosition; - }, [configList, sizeMap]); + }, [configList, sizeMap, stack]); return [notificationPosition, setNodeSize] as const; } diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx index 99cab1a..6cc3212 100644 --- a/src/hooks/useNotification.tsx +++ b/src/hooks/useNotification.tsx @@ -1,43 +1,27 @@ -import type { CSSMotionProps } from '@rc-component/motion'; import { useEvent } from '@rc-component/util'; import * as React from 'react'; import Notifications, { type NotificationsProps, type NotificationsRef } from '../Notifications'; -import type { - NotificationClassNames, - NotificationListConfig, - NotificationStyles, -} from '../NotificationList'; -import type { Placement, StackConfig } from '../NotificationList'; +import type { NotificationListConfig } from '../NotificationList'; +import type { Placement } from '../NotificationList'; const defaultGetContainer = () => document.body; // ========================= Types ========================== type OptionalConfig = Partial; -type SharedConfig = Pick; - -export interface NotificationConfig { - // Style - prefixCls?: string; - className?: (placement: Placement) => string; - style?: (placement: Placement) => React.CSSProperties; - classNames?: NotificationClassNames; - styles?: NotificationStyles; +type SharedConfig = Pick< + NotificationListConfig, + 'placement' | 'closable' | 'duration' | 'showProgress' +>; +export interface NotificationConfig extends Omit { // UI placement?: Placement; getContainer?: () => HTMLElement | ShadowRoot; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); // Behavior closable?: NotificationListConfig['closable']; duration?: number | false | null; - pauseOnHover?: boolean; - maxCount?: number; - stack?: StackConfig; - - // Function - onAllRemoved?: VoidFunction; - renderNotifications?: NotificationsProps['renderNotifications']; + showProgress?: NotificationListConfig['showProgress']; } export interface NotificationAPI { @@ -96,6 +80,7 @@ export default function useNotification( placement, closable, duration, + showProgress, pauseOnHover, classNames, styles, @@ -110,6 +95,7 @@ export default function useNotification( placement, closable, duration, + showProgress, }; // ========================= Holder ========================= diff --git a/src/interface.ts b/src/interface.ts new file mode 100644 index 0000000..e05a61c --- /dev/null +++ b/src/interface.ts @@ -0,0 +1,4 @@ +export interface StackConfig { + threshold?: number; + offset?: number; +} diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 628bbcb..7573bec 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -40,7 +40,7 @@ describe('stack', () => { expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5); expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); - fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); + fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); }); @@ -74,16 +74,51 @@ describe('stack', () => { expect(document.querySelector('.rc-notification-stack')).toBeTruthy(); expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); - fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); + fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); fireEvent.click(document.querySelector('.rc-notification-notice-close')); expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4); expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); - // mouseleave will not triggerred if notice is closed - fireEvent.mouseEnter(document.querySelector('.rc-notification-notice-wrapper')); - fireEvent.mouseLeave(document.querySelector('.rc-notification-notice-wrapper')); + fireEvent.mouseLeave(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); }); + + it('passes stack offset to list position when collapsed', () => { + const Demo = () => { + const [api, holder] = useNotification({ + stack: { threshold: 1, offset: 12 }, + }); + + return ( + <> +
, + duration: false, + }, + { + key: 'second', + content:
Second
, + duration: false, + }, + ]} + />, + ); + + const firstNotice = document + .querySelector('.context-content-first') + ?.closest('.rc-notification-notice'); + const secondNotice = document + .querySelector('.context-content-second') + ?.closest('.rc-notification-notice'); + + expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('58px'); + expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); + + getComputedStyleSpy.mockRestore(); + offsetHeightSpy.mockRestore(); + }); }); From 8941e6188f7445368facb2313d9c36bcadd0db7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 30 Mar 2026 17:23:30 +0800 Subject: [PATCH 23/76] refactor: move useStack hook from legacy to hooks directory Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 9 +++++++++ src/NotificationList.tsx | 2 +- src/hooks/useStack.ts | 24 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useStack.ts diff --git a/assets/geek.less b/assets/geek.less index e79351a..0ecbf82 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -84,6 +84,15 @@ } } } + + &-stack { + &:not(.@{notificationPrefixCls}-stack-expanded) { + .@{notificationPrefixCls}-list-item:nth-last-child(n + 4) { + opacity: 0; + pointer-events: none; + } + } + } } .notification-fade { diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index cfb3295..d427c5c 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -4,7 +4,6 @@ import { clsx } from 'clsx'; import * as React from 'react'; import type { StackConfig } from './interface'; import { NotificationContext } from './legacy/NotificationProvider'; -import useStack from './legacy/hooks/useStack'; import Notification, { type NotificationClassNames, type NotificationProps, @@ -12,6 +11,7 @@ import Notification, { } from './Notification'; import useListPosition from './hooks/useListPosition'; import useListScroll from './hooks/useListScroll'; +import useStack from './hooks/useStack'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; export type { StackConfig } from './interface'; diff --git a/src/hooks/useStack.ts b/src/hooks/useStack.ts new file mode 100644 index 0000000..882a2f6 --- /dev/null +++ b/src/hooks/useStack.ts @@ -0,0 +1,24 @@ +import type { StackConfig } from '../interface'; + +const DEFAULT_OFFSET = 8; +const DEFAULT_THRESHOLD = 3; + +type StackParams = Required; + +type UseStack = (config?: boolean | StackConfig) => [boolean, StackParams]; + +const useStack: UseStack = (config) => { + const result: StackParams = { + offset: DEFAULT_OFFSET, + threshold: DEFAULT_THRESHOLD, + }; + + if (config && typeof config === 'object') { + result.offset = config.offset ?? DEFAULT_OFFSET; + result.threshold = config.threshold ?? DEFAULT_THRESHOLD; + } + + return [!!config, result]; +}; + +export default useStack; From a1bd974d0c4c5d59cddba39ae7b26324ef5030af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 30 Mar 2026 17:53:34 +0800 Subject: [PATCH 24/76] refactor: reorganize Notification props and simplify CSS structure Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 24 +++++++++---------- src/Notification.tsx | 50 +++++++++++++++++++++++++--------------- src/NotificationList.tsx | 16 ++++--------- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/assets/geek.less b/assets/geek.less index 0ecbf82..a3398b3 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -4,19 +4,17 @@ @notificationMotionOffset: 64px; .@{notificationPrefixCls} { - &-list { - position: fixed; - top: 0; - right: 0; - z-index: 1000; - width: 360px; - height: 100vh; - padding: 24px; - box-sizing: border-box; - pointer-events: none; - overflow: hidden; - overscroll-behavior: contain; - } + position: fixed; + top: 0; + right: 0; + z-index: 1000; + width: 360px; + height: 100vh; + padding: 24px; + box-sizing: border-box; + pointer-events: none; + overflow: hidden; + overscroll-behavior: contain; &-list-content { position: relative; diff --git a/src/Notification.tsx b/src/Notification.tsx index 566b0f5..fca8566 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -21,27 +21,33 @@ export interface NotificationStyles { } export interface NotificationProps { + // Style prefixCls?: string; + className?: string; + style?: React.CSSProperties; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + + // UI content?: React.ReactNode; actions?: React.ReactNode; close?: React.ReactNode; closable?: | boolean | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); - duration?: number | false | null; - showProgress?: boolean; - times?: number; - hovering?: boolean; offset?: { x: number; y: number; }; + + // Behavior + duration?: number | false | null; + showProgress?: boolean; + times?: number; + hovering?: boolean; pauseOnHover?: boolean; - className?: string; - style?: React.CSSProperties; - classNames?: NotificationClassNames; - styles?: NotificationStyles; - props?: React.HTMLAttributes & Record; + + // Function onClick?: React.MouseEventHandler; onClose?: () => void; onCloseInternal?: VoidFunction; @@ -49,21 +55,27 @@ export interface NotificationProps { const Notification = React.forwardRef((props, ref) => { const { + // Style prefixCls = 'rc-notification', + className, + style, + classNames, + styles, + + // UI content, actions, close, closable, + offset, + + // Behavior duration = 4.5, showProgress, hovering: forcedHovering, - offset, pauseOnHover = true, - className, - style, - classNames, - styles, - props: divProps, + + // Function onClick, onClose, onCloseInternal, @@ -121,8 +133,9 @@ const Notification = React.forwardRef((props, // ========================= Render ========================= return (
((props, : null), ...style, }} + // Events onClick={onClick} onMouseEnter={(event) => { setHovering(true); if (pauseOnHover) { onPause(); } - divProps?.onMouseEnter?.(event); + rootProps?.onMouseEnter?.(event); }} onMouseLeave={(event) => { setHovering(false); if (pauseOnHover && !forcedHovering) { onResume(); } - divProps?.onMouseLeave?.(event); + rootProps?.onMouseLeave?.(event); }} >
= (props) => { return (
{ setListHovering(true); From 0204703f538e19ff4991a7cb189cfaa966ca1d1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 30 Mar 2026 18:03:51 +0800 Subject: [PATCH 25/76] refactor: simplify Notification callback and extract hover handlers Co-Authored-By: Claude Opus 4.6 --- src/Notification.tsx | 49 +++++++++++++++++++++++----------------- src/NotificationList.tsx | 3 ++- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/Notification.tsx b/src/Notification.tsx index fca8566..fb62182 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -39,6 +39,7 @@ export interface NotificationProps { x: number; y: number; }; + props?: React.HTMLAttributes & Record; // Behavior duration?: number | false | null; @@ -49,8 +50,9 @@ export interface NotificationProps { // Function onClick?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; onClose?: () => void; - onCloseInternal?: VoidFunction; } const Notification = React.forwardRef((props, ref) => { @@ -68,6 +70,7 @@ const Notification = React.forwardRef((props, close, closable, offset, + props: rootProps, // Behavior duration = 4.5, @@ -77,16 +80,16 @@ const Notification = React.forwardRef((props, // Function onClick, + onMouseEnter, + onMouseLeave, onClose, - onCloseInternal, } = props; - const [hovering, setHovering] = React.useState(false); + const [percent, setPercent] = React.useState(0); const noticePrefixCls = `${prefixCls}-notice`; // ========================= Close ========================== const onEventClose = useEvent(onClose); - const onEventCloseInternal = useEvent(onCloseInternal); const offsetRef = React.useRef(offset); const closableObj = React.useMemo(() => { if (typeof closable === 'object' && closable !== null) { @@ -104,12 +107,13 @@ const Notification = React.forwardRef((props, } // ======================== Duration ======================== + const [hovering, setHovering] = React.useState(false); + const [onResume, onPause] = useNoticeTimer( duration, () => { closableObj.onClose?.(); onEventClose(); - onEventCloseInternal(); }, setPercent, !!showProgress, @@ -130,6 +134,23 @@ const Notification = React.forwardRef((props, } }, [forcedHovering, hovering, onPause, onResume, pauseOnHover]); + // ========================= Hover ========================== + function onInternalMouseEnter(event: React.MouseEvent) { + setHovering(true); + if (pauseOnHover) { + onPause(); + } + onMouseEnter?.(event); + } + + function onInternalMouseLeave(event: React.MouseEvent) { + setHovering(false); + if (pauseOnHover && !forcedHovering) { + onResume(); + } + onMouseLeave?.(event); + } + // ========================= Render ========================= return (
((props, }} // Events onClick={onClick} - onMouseEnter={(event) => { - setHovering(true); - if (pauseOnHover) { - onPause(); - } - rootProps?.onMouseEnter?.(event); - }} - onMouseLeave={(event) => { - setHovering(false); - if (pauseOnHover && !forcedHovering) { - onResume(); - } - rootProps?.onMouseLeave?.(event); - }} + onMouseEnter={onInternalMouseEnter} + onMouseLeave={onInternalMouseLeave} >
((props, if (event.key === 'Enter' || event.code === 'Enter') { closableObj.onClose?.(); onEventClose(); - onEventCloseInternal(); } }} onClick={(e) => { @@ -191,7 +199,6 @@ const Notification = React.forwardRef((props, e.stopPropagation(); closableObj.onClose?.(); onEventClose(); - onEventCloseInternal(); }} > {closeContent} diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index d426fe2..9b117e1 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -208,7 +208,8 @@ const NotificationList: React.FC = (props) => { }} hovering={stackEnabled && listHovering} pauseOnHover={config.pauseOnHover ?? pauseOnHover} - onCloseInternal={() => { + onClose={() => { + config.onClose?.(); onNoticeClose?.(key); }} /> From d410271e1812b190307ed3e4abceb734f4fe4e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Wed, 1 Apr 2026 12:03:13 +0800 Subject: [PATCH 26/76] refactor closable handling --- Agent.md | 6 ++++++ src/Notification.tsx | 40 +++++++++++++++++---------------------- src/NotificationList.tsx | 15 +++++++++++---- src/hooks/useClosable.ts | 41 ++++++++++++++++++++++++++++++++++++++++ tests/index.test.tsx | 20 ++++++++++++++++++++ 5 files changed, 95 insertions(+), 27 deletions(-) create mode 100644 Agent.md create mode 100644 src/hooks/useClosable.ts diff --git a/Agent.md b/Agent.md new file mode 100644 index 0000000..99883a7 --- /dev/null +++ b/Agent.md @@ -0,0 +1,6 @@ +# Agent Rules + +## Refactor + +- Do not modify any files under `src/legacy` when handling refactor requests unless the user explicitly asks for legacy changes. +- For refactor requests, do not run tests by default. Only run tests if the user explicitly asks for them or the task clearly requires verification. diff --git a/src/Notification.tsx b/src/Notification.tsx index fb62182..486a04d 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; import { clsx } from 'clsx'; -import pickAttrs from '@rc-component/util/lib/pickAttrs'; import useNoticeTimer from './hooks/useNoticeTimer'; import { useEvent } from '@rc-component/util'; +import useClosable, { type ClosableType } from './hooks/useClosable'; export interface NotificationClassNames { wrapper?: string; @@ -32,9 +32,7 @@ export interface NotificationProps { content?: React.ReactNode; actions?: React.ReactNode; close?: React.ReactNode; - closable?: - | boolean - | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); + closable?: ClosableType; offset?: { x: number; y: number; @@ -90,21 +88,10 @@ const Notification = React.forwardRef((props, // ========================= Close ========================== const onEventClose = useEvent(onClose); - const offsetRef = React.useRef(offset); - const closableObj = React.useMemo(() => { - if (typeof closable === 'object' && closable !== null) { - return closable; - } - return {}; - }, [closable]); - const closeContent = close === undefined ? (closableObj.closeIcon ?? 'x') : close; - const mergedClosable = close !== undefined ? close !== null : !!closable; - const ariaProps = pickAttrs(closableObj, true); - - if (offset) { - offsetRef.current = offset; - } + const [closableEnabled, closableConfig, closeBtnAriaProps] = useClosable(closable); + const closeContent = close === undefined ? closableConfig.closeIcon : close; + const mergedClosable = close !== undefined ? close !== null : closableEnabled; // ======================== Duration ======================== const [hovering, setHovering] = React.useState(false); @@ -112,14 +99,13 @@ const Notification = React.forwardRef((props, const [onResume, onPause] = useNoticeTimer( duration, () => { - closableObj.onClose?.(); + closableConfig.onClose?.(); onEventClose(); }, setPercent, !!showProgress, ); - const mergedOffset = offset ?? offsetRef.current; const validPercent = 100 - Math.min(Math.max(percent * 100, 0), 100); React.useEffect(() => { @@ -151,6 +137,14 @@ const Notification = React.forwardRef((props, onMouseLeave?.(event); } + // ======================== Position ======================== + const offsetRef = React.useRef(offset); + if (offset) { + offsetRef.current = offset; + } + + const mergedOffset = offset ?? offsetRef.current; + // ========================= Render ========================= return (
((props, )} + {actions &&
{actions}
} + {showProgress && typeof duration === 'number' && duration > 0 && ( - {validPercent}% - + /> )} - - {actions &&
{actions}
}
); }); diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 458b387..27e0f83 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -16,7 +16,7 @@ import useStack from './hooks/useStack'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; export type { StackConfig } from './interface'; -export interface NotificationListConfig extends NotificationProps { +export interface NotificationListConfig extends Omit { key: React.Key; placement?: Placement; times?: number; diff --git a/src/hooks/useClosable.ts b/src/hooks/useClosable.ts index 176aa88..b2eb6de 100644 --- a/src/hooks/useClosable.ts +++ b/src/hooks/useClosable.ts @@ -33,7 +33,7 @@ export default function useClosable( const closableConfig = React.useMemo( () => ({ ...closableObj, - closeIcon: closableObj.closeIcon ?? '×', + closeIcon: 'closeIcon' in closableObj ? closableObj.closeIcon : '×', disabled: closableObj.disabled ?? false, }), [closableObj], From b9a2a0eff28ce56cb00dbe887ea861e2f174df57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 2 Apr 2026 17:59:29 +0800 Subject: [PATCH 29/76] refactor: replace content with description and add title/icon props Co-Authored-By: Claude Opus 4.6 --- assets/index.less | 6 +-- docs/examples/NotificationList.tsx | 2 +- docs/examples/context.tsx | 4 +- docs/examples/hooks.tsx | 8 ++-- docs/examples/maxCount.tsx | 2 +- docs/examples/showProgress.tsx | 4 +- docs/examples/stack.tsx | 2 +- src/Notification.tsx | 37 +++++++++++---- src/NotificationList.tsx | 13 ++++-- tests/hooks.test.tsx | 18 ++++---- tests/index.test.tsx | 74 +++++++++++++++--------------- tests/stack.test.tsx | 10 ++-- 12 files changed, 102 insertions(+), 78 deletions(-) diff --git a/assets/index.less b/assets/index.less index eaa60f6..4ee3617 100644 --- a/assets/index.less +++ b/assets/index.less @@ -56,12 +56,12 @@ width: 300px; } - // Content - &-content { + // Section + &-section { padding: 7px 20px 7px 10px; } - &-closable &-content { + &-closable &-section { padding-right: 20px; } diff --git a/docs/examples/NotificationList.tsx b/docs/examples/NotificationList.tsx index 6a54ea1..94703ec 100644 --- a/docs/examples/NotificationList.tsx +++ b/docs/examples/NotificationList.tsx @@ -23,7 +23,7 @@ const Demo = () => { (key: number): NotificationListConfig => ({ key, duration: false, - content: `Config ${key + 1}`, + description: `Config ${key + 1}`, }), [], ); diff --git a/docs/examples/context.tsx b/docs/examples/context.tsx index 9bcf104..6401604 100644 --- a/docs/examples/context.tsx +++ b/docs/examples/context.tsx @@ -7,7 +7,7 @@ import motion from './motion'; const Context = React.createContext({ name: 'light' }); const NOTICE = { - content: simple show, + description: simple show, onClose() { console.log('simple close'); }, @@ -24,7 +24,7 @@ const Demo = () => { onClick={() => { open({ ...NOTICE, - content: {({ name }) => `Hi ${name}!`}, + description: {({ name }) => `Hi ${name}!`}, props: { 'data-testid': 'my-data-testid', }, diff --git a/docs/examples/hooks.tsx b/docs/examples/hooks.tsx index e0ff048..04782e2 100644 --- a/docs/examples/hooks.tsx +++ b/docs/examples/hooks.tsx @@ -32,7 +32,7 @@ const App = () => { type="button" onClick={() => { notice.open({ - content: `${new Date().toISOString()}`, + description: `${new Date().toISOString()}`, }); }} > @@ -44,7 +44,7 @@ const App = () => { type="button" onClick={() => { notice.open({ - content: `${Array(Math.round(Math.random() * 5) + 1) + description: `${Array(Math.round(Math.random() * 5) + 1) .fill(1) .map(() => new Date().toISOString()) .join('\n')}`, @@ -60,7 +60,7 @@ const App = () => { type="button" onClick={() => { notice.open({ - content: `${Array(5) + description: `${Array(5) .fill(1) .map(() => new Date().toISOString()) .join('\n')}`, @@ -78,7 +78,7 @@ const App = () => { type="button" onClick={() => { notice.open({ - content: `No Close! ${new Date().toISOString()}`, + description: `No Close! ${new Date().toISOString()}`, duration: null, closable: false, key: 'No Close', diff --git a/docs/examples/maxCount.tsx b/docs/examples/maxCount.tsx index 229d635..3bd6f32 100644 --- a/docs/examples/maxCount.tsx +++ b/docs/examples/maxCount.tsx @@ -12,7 +12,7 @@ export default () => { - +
+ + +
{holder} ); diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 6d0e113..f450d41 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -27,7 +27,7 @@ export interface NotificationListConfig extends Omit void; } -function assignRef(ref: React.Ref, value: T | null) { - if (typeof ref === 'function') { - ref(value); - } else if (ref) { - (ref as React.MutableRefObject).current = value; - } -} - const NotificationList: React.FC = (props) => { const { configList = [], @@ -118,8 +110,6 @@ const NotificationList: React.FC = (props) => { // ========================= Render ========================= const listPrefixCls = `${prefixCls}-list`; - const itemPrefixCls = `${listPrefixCls}-item`; - const noticeWrapperCls = `${prefixCls}-notice-wrapper`; return (
Date: Thu, 9 Apr 2026 15:56:53 +0800 Subject: [PATCH 36/76] refactor: improve stack position calculation using bottom edge Co-Authored-By: Claude Opus 4.6 --- src/hooks/useListPosition/index.ts | 14 +++-- tests/stack.test.tsx | 94 ++++++++++++++++++------------ 2 files changed, 65 insertions(+), 43 deletions(-) diff --git a/src/hooks/useListPosition/index.ts b/src/hooks/useListPosition/index.ts index 65f1ffe..1e03301 100644 --- a/src/hooks/useListPosition/index.ts +++ b/src/hooks/useListPosition/index.ts @@ -16,23 +16,25 @@ export default function useListPosition( const notificationPosition = React.useMemo(() => { let offsetY = 0; + let offsetBottom = 0; const nextNotificationPosition = new Map(); configList .slice() .reverse() - .forEach((config) => { + .forEach((config, index) => { const key = String(config.key); + const height = sizeMap[key]?.height ?? 0; const nodePosition = { x: 0, - y: offsetY, + y: stack && index > 0 ? offsetBottom + (stack.offset ?? 0) - height : offsetY, }; nextNotificationPosition.set(key, nodePosition); - offsetY += (stack ? stack.offset : sizeMap[key]?.height) ?? 0; - - if (!stack) { - offsetY += gap; + if (stack) { + offsetBottom = nodePosition.y + height; + } else { + offsetY += height + gap; } }); diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 94af330..104afbd 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -86,57 +86,77 @@ describe('stack', () => { expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); }); - it('passes stack offset to list position when collapsed', () => { - const Demo = () => { - const countRef = React.useRef(0); - const [api, holder] = useNotification({ - stack: { threshold: 1, offset: 12 }, - }); - - return ( - <> -
, + duration: false, + }, + { + key: 'second', + description:
Second
, + duration: false, + }, + ]} + />, + ); - for (let i = 0; i < 2; i++) { - fireEvent.click(container.querySelector('button')); - } + const firstNotice = document + .querySelector('.context-content-first') + ?.closest('.rc-notification-notice'); + const secondNotice = document + .querySelector('.context-content-second') + ?.closest('.rc-notification-notice'); - const notices = Array.from(document.querySelectorAll('.rc-notification-notice')); - const offsetList = notices.map((notice) => notice.style.getPropertyValue('--notification-y')); + const getBottom = (notice: HTMLElement | undefined | null) => + (notice ? parseFloat(notice.style.getPropertyValue('--notification-y')) : 0) + + (notice?.offsetHeight ?? 0); - expect(notices[0].querySelector('.context-content-0')).toBeTruthy(); - expect(notices[1].querySelector('.context-content-1')).toBeTruthy(); - expect(offsetList).toEqual(['12px', '0px']); + expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('-28px'); + expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); + expect(getBottom(firstNotice) - getBottom(secondNotice)).toBe(12); fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); - expect( - notices.every((notice) => notice.style.getPropertyValue('--notification-y') === '0px'), - ).toBeTruthy(); + expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('40px'); + expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); + + offsetHeightSpy.mockRestore(); }); it('passes list css gap to list position when expanded', () => { const offsetHeightSpy = vi .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') .mockImplementation(function mockOffsetHeight() { - return this.classList?.contains('rc-notification-notice-wrapper') ? 50 : 0; + if ( + this.classList?.contains('rc-notification-notice-wrapper') || + this.classList?.contains('rc-notification-notice') + ) { + return 50; + } + + return 0; }); const originGetComputedStyle = window.getComputedStyle; const getComputedStyleSpy = vi From bcba5d07415382313a484083f75bf6f4348b49c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 17:07:53 +0800 Subject: [PATCH 37/76] refactor: add scale and clip-path effects for stack notifications - Add notificationIndex and stackInThreshold props to Notification component - Implement CSS scale transformation based on notification index - Add clip-path animation for stacked notifications - Update tests to verify notification index attributes Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 29 +++++++++++++++++++++++------ src/Notification.tsx | 33 +++++++++++++++++++++++---------- src/NotificationList.tsx | 8 ++++++-- tests/stack.test.tsx | 21 +++++++++++++++++++++ 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/assets/geek.less b/assets/geek.less index 4783837..4f880bb 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -1,5 +1,6 @@ @notificationPrefixCls: notification; @notificationMotionDuration: 0.3s; +// @notificationMotionDuration: 10s; @notificationMotionEase: cubic-bezier(0.22, 1, 0.36, 1); @notificationMotionOffset: 64px; @@ -42,10 +43,13 @@ // transform: translate3d(var(--notification-x, 0), var(--notification-y, 0), 0); right: var(--notification-x, 0); top: var(--notification-y, 0); + transform: scale(var(--notification-scale, 1)); transition: transform @notificationMotionDuration @notificationMotionEase, inset @notificationMotionDuration @notificationMotionEase, + clip-path @notificationMotionDuration @notificationMotionEase, opacity @notificationMotionDuration @notificationMotionEase; + transform-origin: center bottom; padding: 14px 16px; border: 2px solid #111; border-radius: 14px; @@ -89,8 +93,21 @@ } &-stack { + .@{notificationPrefixCls}-notice { + clip-path: inset(-50% -50% -50% -50%); + } + &:not(.@{notificationPrefixCls}-stack-expanded) { - .@{notificationPrefixCls}-list-item:nth-last-child(n + 4) { + .@{notificationPrefixCls}-notice { + --notification-scale: ~'calc(1 - min(var(--notification-index, 0), 2) * 0.06)'; + clip-path: inset(50% -50% -50% -50%); + } + + .@{notificationPrefixCls}-notice[data-notification-index='0'] { + clip-path: inset(-50% -50% -50% -50%); + } + + .@{notificationPrefixCls}-notice:not(.@{notificationPrefixCls}-notice-stack-in-threshold) { opacity: 0; pointer-events: none; } @@ -106,28 +123,28 @@ .notification-fade-appear-prepare, .notification-fade-enter-prepare { opacity: 0; - transform: translateX(@notificationMotionOffset); + transform: translateX(@notificationMotionOffset) scale(var(--notification-scale, 1)); transition: none; } .notification-fade-appear-start, .notification-fade-enter-start { opacity: 0; - transform: translateX(@notificationMotionOffset); + transform: translateX(@notificationMotionOffset) scale(var(--notification-scale, 1)); } .notification-fade-appear-active, .notification-fade-enter-active { opacity: 1; - transform: translateX(0); + transform: translateX(0) scale(var(--notification-scale, 1)); } .notification-fade-leave-start { opacity: 1; - transform: translateX(0); + transform: translateX(0) scale(var(--notification-scale, 1)); } .notification-fade-leave-active { opacity: 0; - transform: translateX(@notificationMotionOffset); + transform: translateX(@notificationMotionOffset) scale(var(--notification-scale, 1)); } diff --git a/src/Notification.tsx b/src/Notification.tsx index c0ea953..56be5b5 100644 --- a/src/Notification.tsx +++ b/src/Notification.tsx @@ -47,6 +47,8 @@ export interface NotificationProps { x: number; y: number; }; + notificationIndex?: number; + stackInThreshold?: boolean; props?: React.HTMLAttributes & Record; // Behavior @@ -81,6 +83,8 @@ const Notification = React.forwardRef((props, actions, closable, offset, + notificationIndex, + stackInThreshold, props: rootProps, // Behavior @@ -156,6 +160,7 @@ const Notification = React.forwardRef((props, } const mergedOffset = offset ?? offsetRef.current; + const mergedNotificationIndex = notificationIndex ?? 0; // ======================== Content ========================= const titleNode = @@ -199,24 +204,32 @@ const Notification = React.forwardRef((props, } // ========================= Render ========================= + const mergedStyle: React.CSSProperties & { + '--notification-index'?: number; + '--notification-x'?: string; + '--notification-y'?: string; + } = { + '--notification-index': mergedNotificationIndex, + ...styles?.root, + ...style, + }; + + if (mergedOffset) { + mergedStyle['--notification-x'] = `${mergedOffset.x}px`; + mergedStyle['--notification-y'] = `${mergedOffset.y}px`; + } + return (
= (props) => { } }} > - {({ config, className: motionClassName, style: motionStyle }, nodeRef) => { - const { key, placement: _placement, ...notificationConfig } = config; + {({ config, className: motionClassName, style: motionStyle, index = 0 }, nodeRef) => { + const { key, placement: itemPlacement, ...notificationConfig } = config; const strKey = String(key); + const notificationIndex = keyList.length - index - 1; + const stackInThreshold = stackEnabled && notificationIndex < threshold; const setItemRef = (node: HTMLDivElement | null) => { setNodeSize(strKey, node); @@ -167,6 +169,8 @@ const NotificationList: React.FC = (props) => { ref={composeRef(nodeRef, setItemRef)} prefixCls={prefixCls} offset={notificationPosition.get(strKey)} + notificationIndex={notificationIndex} + stackInThreshold={stackInThreshold} className={clsx(contextClassNames?.notice, config.className)} style={config.style} classNames={{ diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 104afbd..ab0f35b 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -41,6 +41,25 @@ describe('stack', () => { expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5); expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); + const notices = Array.from(document.querySelectorAll('.rc-notification-notice')); + expect(notices.map((notice) => notice.getAttribute('data-notification-index'))).toEqual([ + '4', + '3', + '2', + '1', + '0', + ]); + expect( + notices + .slice(0, 2) + .every((notice) => !notice.matches('.rc-notification-notice-stack-in-threshold')), + ).toBeTruthy(); + expect( + notices + .slice(2) + .every((notice) => notice.matches('.rc-notification-notice-stack-in-threshold')), + ).toBeTruthy(); + fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); }); @@ -203,6 +222,8 @@ describe('stack', () => { .querySelector('.context-content-second') ?.closest('.rc-notification-notice'); + expect(firstNotice?.getAttribute('data-notification-index')).toBe('1'); + expect(secondNotice?.getAttribute('data-notification-index')).toBe('0'); expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('58px'); expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); From 1227f86d413f1a9bf44f89d3aa72ecf2b73dbb04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 17:14:00 +0800 Subject: [PATCH 38/76] feat: add touch scroll support for stack notifications - Implement touch event handlers (touchStart, touchMove, touchEnd) in useListScroll - Add touch scroll interaction for mobile devices - Include unit test for touch scroll functionality Co-Authored-By: Claude Opus 4.6 --- src/NotificationList.tsx | 9 +++---- src/hooks/useListScroll.ts | 48 ++++++++++++++++++++++++++++++++++++- tests/stack.test.tsx | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 7c5c3ae..93271be 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -90,10 +90,8 @@ const NotificationList: React.FC = (props) => { const [gap, setGap] = React.useState(0); const [notificationPosition, setNodeSize] = useListPosition(configList, stackPosition, gap); - const { contentRef, onWheel, scrollOffset, viewportRef } = useListScroll( - keyList, - notificationPosition, - ); + const { contentRef, onTouchEnd, onTouchMove, onTouchStart, onWheel, scrollOffset, viewportRef } = + useListScroll(keyList, notificationPosition); React.useEffect(() => { const listNode = contentRef.current; @@ -124,6 +122,9 @@ const NotificationList: React.FC = (props) => { [`${prefixCls}-stack-expanded`]: expanded, }, )} + onTouchEnd={onTouchEnd} + onTouchMove={onTouchMove} + onTouchStart={onTouchStart} onWheel={onWheel} onMouseEnter={() => { setListHovering(true); diff --git a/src/hooks/useListScroll.ts b/src/hooks/useListScroll.ts index f2f1814..0b54690 100644 --- a/src/hooks/useListScroll.ts +++ b/src/hooks/useListScroll.ts @@ -11,8 +11,10 @@ function getViewportInnerHeight(node: HTMLDivElement | null) { } const { paddingBottom, paddingTop } = window.getComputedStyle(node); + const topPadding = parseFloat(paddingTop) || 0; + const bottomPadding = parseFloat(paddingBottom) || 0; - return node.clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom); + return node.clientHeight - topPadding - bottomPadding; } function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) { @@ -28,6 +30,8 @@ export default function useListScroll( ) { const viewportRef = React.useRef(null); const contentRef = React.useRef(null); + const touchStartYRef = React.useRef(null); + const touchStartOffsetRef = React.useRef(0); const prevKeyListRef = React.useRef(keyList); const prevNotificationPositionRef = React.useRef>(new Map()); const scrollOffsetRef = React.useRef(0); @@ -107,8 +111,50 @@ export default function useListScroll( [syncScrollOffset], ); + const onTouchStart = React.useCallback((event: React.TouchEvent) => { + const touch = event.touches[0]; + + if (!touch) { + return; + } + + touchStartYRef.current = touch.clientY; + touchStartOffsetRef.current = scrollOffsetRef.current; + }, []); + + const onTouchMove = React.useCallback( + (event: React.TouchEvent) => { + const touch = event.touches[0]; + const touchStartY = touchStartYRef.current; + const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); + + if (!touch || touchStartY === null || !maxScroll) { + return; + } + + event.preventDefault(); + + const nextOffset = clampScrollOffset( + touchStartOffsetRef.current + touch.clientY - touchStartY, + maxScroll, + ); + + if (nextOffset !== scrollOffsetRef.current) { + syncScrollOffset(nextOffset); + } + }, + [syncScrollOffset], + ); + + const onTouchEnd = React.useCallback(() => { + touchStartYRef.current = null; + }, []); + return { contentRef, + onTouchEnd, + onTouchMove, + onTouchStart, onWheel, scrollOffset, viewportRef, diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index ab0f35b..89967c9 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -230,4 +230,53 @@ describe('stack', () => { getComputedStyleSpy.mockRestore(); offsetHeightSpy.mockRestore(); }); + + it('supports touch scroll on mobile', () => { + const clientHeightSpy = vi + .spyOn(HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(function mockClientHeight() { + if (this.classList?.contains('rc-notification-list')) { + return 120; + } + + return 0; + }); + const scrollHeightSpy = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockImplementation(function mockScrollHeight() { + if (this.classList?.contains('rc-notification-list-content')) { + return 300; + } + + return 0; + }); + + render( + ({ + key: index, + description: `Notice ${index}`, + duration: false, + }))} + />, + ); + + const list = document.querySelector('.rc-notification-list'); + const content = document.querySelector('.rc-notification-list-content'); + + fireEvent.touchStart(list!, { + touches: [{ clientY: 120 }], + }); + fireEvent.touchMove(list!, { + touches: [{ clientY: 60 }], + }); + + expect(content?.style.transform).toBe('translate3d(0, -60px, 0)'); + + fireEvent.touchEnd(list!); + + clientHeightSpy.mockRestore(); + scrollHeightSpy.mockRestore(); + }); }); From 5f52d3c1d82ce8a1b343c21108678a8a18c3468f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 17:19:27 +0800 Subject: [PATCH 39/76] fix: add pointer-events auto for expanded stack Ensure expanded stack has proper pointer-events to allow interactions with notifications behind the stack container. Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 4 ++++ assets/index.less | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/assets/geek.less b/assets/geek.less index 4f880bb..291f2dc 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -35,6 +35,10 @@ width: 100%; } + &-stack-expanded { + pointer-events: auto; + } + &-notice { position: absolute; pointer-events: auto; diff --git a/assets/index.less b/assets/index.less index 4ee3617..3011182 100644 --- a/assets/index.less +++ b/assets/index.less @@ -35,6 +35,10 @@ } // ========================= Notice ========================= + &-stack-expanded { + pointer-events: auto; + } + &-notice { position: relative; display: block; From 0c4bda300fb881ee4b8df5ea089915aa7aadd877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 17:31:46 +0800 Subject: [PATCH 40/76] fix: reset scroll offset when stack collapses - Add expanded parameter to useListScroll hook - Reset scroll offset to 0 when stack collapses after hover leave - Add test for scroll reset behavior Co-Authored-By: Claude Opus 4.6 --- src/NotificationList.tsx | 2 +- src/hooks/useListScroll.ts | 9 +++++++ tests/stack.test.tsx | 49 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 93271be..07d31a8 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -91,7 +91,7 @@ const NotificationList: React.FC = (props) => { const [gap, setGap] = React.useState(0); const [notificationPosition, setNodeSize] = useListPosition(configList, stackPosition, gap); const { contentRef, onTouchEnd, onTouchMove, onTouchStart, onWheel, scrollOffset, viewportRef } = - useListScroll(keyList, notificationPosition); + useListScroll(keyList, notificationPosition, expanded); React.useEffect(() => { const listNode = contentRef.current; diff --git a/src/hooks/useListScroll.ts b/src/hooks/useListScroll.ts index 0b54690..f65f8e2 100644 --- a/src/hooks/useListScroll.ts +++ b/src/hooks/useListScroll.ts @@ -27,6 +27,7 @@ function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivE export default function useListScroll( keyList: string[], notificationPosition: Map, + expanded = false, ) { const viewportRef = React.useRef(null); const contentRef = React.useRef(null); @@ -92,6 +93,14 @@ export default function useListScroll( }; }, [syncScrollOffset]); + React.useEffect(() => { + if (!expanded) { + touchStartYRef.current = null; + touchStartOffsetRef.current = 0; + syncScrollOffset(0); + } + }, [expanded, syncScrollOffset]); + const onWheel = React.useCallback( (event: React.WheelEvent) => { const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 89967c9..4e22f51 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -279,4 +279,53 @@ describe('stack', () => { clientHeightSpy.mockRestore(); scrollHeightSpy.mockRestore(); }); + + it('resets scroll offset when stack collapses after hover leave', () => { + const clientHeightSpy = vi + .spyOn(HTMLElement.prototype, 'clientHeight', 'get') + .mockImplementation(function mockClientHeight() { + if (this.classList?.contains('rc-notification-list')) { + return 120; + } + + return 0; + }); + const scrollHeightSpy = vi + .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') + .mockImplementation(function mockScrollHeight() { + if (this.classList?.contains('rc-notification-list-content')) { + return 300; + } + + return 0; + }); + + render( + ({ + key: index, + description: `Notice ${index}`, + duration: false, + }))} + />, + ); + + const list = document.querySelector('.rc-notification-list'); + const content = document.querySelector('.rc-notification-list-content'); + + fireEvent.mouseEnter(list!); + fireEvent.wheel(list!, { deltaY: 60 }); + + expect(content?.style.transform).toBe('translate3d(0, -60px, 0)'); + + fireEvent.mouseLeave(list!); + + expect(list).not.toHaveClass('rc-notification-stack-expanded'); + expect(content?.style.transform).toBe('translate3d(0, 0px, 0)'); + + clientHeightSpy.mockRestore(); + scrollHeightSpy.mockRestore(); + }); }); From afdee753a16abd0114e040c277b620842e64313d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 17:37:48 +0800 Subject: [PATCH 41/76] feat: add transform transition for stack content - Add transform transition to list content for smooth animations - Disable transition when stack is expanded for immediate scroll response Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/assets/geek.less b/assets/geek.less index 291f2dc..74b3e50 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -24,6 +24,7 @@ gap: 8px; width: 100%; pointer-events: none; + transition: transform @notificationMotionDuration @notificationMotionEase; will-change: transform; } @@ -37,6 +38,10 @@ &-stack-expanded { pointer-events: auto; + + .@{notificationPrefixCls}-list-content { + transition: none; + } } &-notice { From 08743122201d17268f01cbc7d03b50bc8593230e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 18:00:45 +0800 Subject: [PATCH 42/76] refactor: simplify useListScroll implementation - Consolidate touch refs into single touchStartRef object - Inline helper functions for cleaner code - Add JSDoc comments for better documentation - Simplify syncScrollOffset by removing redundant equality check - Clean up key list change detection logic Co-Authored-By: Claude Opus 4.6 --- src/hooks/useListScroll.ts | 137 ++++++++++++++++--------------------- 1 file changed, 58 insertions(+), 79 deletions(-) diff --git a/src/hooks/useListScroll.ts b/src/hooks/useListScroll.ts index f65f8e2..b6faf0a 100644 --- a/src/hooks/useListScroll.ts +++ b/src/hooks/useListScroll.ts @@ -1,29 +1,24 @@ import * as React from 'react'; import type { NodePosition } from './useListPosition'; -function clampScrollOffset(offset: number, maxScroll: number) { - return Math.min(0, Math.max(-maxScroll, offset)); -} - -function getViewportInnerHeight(node: HTMLDivElement | null) { - if (!node) { +/** + * Measures how much the list content can scroll inside the viewport. + */ +function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) { + if (!viewportNode) { return 0; } - const { paddingBottom, paddingTop } = window.getComputedStyle(node); - const topPadding = parseFloat(paddingTop) || 0; - const bottomPadding = parseFloat(paddingBottom) || 0; - - return node.clientHeight - topPadding - bottomPadding; -} - -function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) { - const viewportHeight = getViewportInnerHeight(viewportNode); - const measuredContentHeight = contentNode?.scrollHeight ?? 0; + const { paddingBottom, paddingTop } = window.getComputedStyle(viewportNode); + const viewportHeight = + viewportNode.clientHeight - (parseFloat(paddingTop) || 0) - (parseFloat(paddingBottom) || 0); - return Math.max(measuredContentHeight - viewportHeight, 0); + return Math.max((contentNode?.scrollHeight ?? 0) - viewportHeight, 0); } +/** + * Manages wheel and touch scrolling for the notification list. + */ export default function useListScroll( keyList: string[], notificationPosition: Map, @@ -31,46 +26,43 @@ export default function useListScroll( ) { const viewportRef = React.useRef(null); const contentRef = React.useRef(null); - const touchStartYRef = React.useRef(null); - const touchStartOffsetRef = React.useRef(0); - const prevKeyListRef = React.useRef(keyList); - const prevNotificationPositionRef = React.useRef>(new Map()); + const touchStartRef = React.useRef<{ y: number; offset: number } | null>(null); + const prevRef = React.useRef({ keyList, notificationPosition }); const scrollOffsetRef = React.useRef(0); const [scrollOffset, setScrollOffset] = React.useState(0); + /** + * Applies the next offset and keeps it within the current scroll bounds. + */ const syncScrollOffset = React.useCallback((nextOffset: number) => { const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); - const mergedOffset = clampScrollOffset(nextOffset, maxScroll); + // Clamp the offset so the content never scrolls past its visible range. + const mergedOffset = Math.max(-maxScroll, Math.min(0, nextOffset)); scrollOffsetRef.current = mergedOffset; - setScrollOffset((prev) => (prev === mergedOffset ? prev : mergedOffset)); + setScrollOffset(mergedOffset); }, []); React.useLayoutEffect(() => { - const prevKeyList = prevKeyListRef.current; - const prevNotificationPosition = prevNotificationPositionRef.current; + const { keyList: prevKeyList, notificationPosition: prevNotificationPosition } = + prevRef.current; + let nextOffset = scrollOffsetRef.current; - if (scrollOffsetRef.current < 0) { - const prependCount = prevKeyList.length - ? keyList.findIndex((key) => key === prevKeyList[0]) - : -1; - const removedCount = keyList.length ? prevKeyList.findIndex((key) => key === keyList[0]) : -1; + if (nextOffset < 0) { + const prevFirstKey = prevKeyList[0]; + const firstKey = keyList[0]; + const prependCount = prevFirstKey === undefined ? -1 : keyList.indexOf(prevFirstKey); + const removedCount = firstKey === undefined ? -1 : prevKeyList.indexOf(firstKey); if (prependCount > 0) { - const prependHeight = notificationPosition.get(prevKeyList[0])?.y ?? 0; - syncScrollOffset(scrollOffsetRef.current - prependHeight); + nextOffset -= notificationPosition.get(prevFirstKey)?.y ?? 0; } else if (removedCount > 0) { - const removedHeight = keyList[0] ? (prevNotificationPosition.get(keyList[0])?.y ?? 0) : 0; - syncScrollOffset(scrollOffsetRef.current + removedHeight); - } else { - syncScrollOffset(scrollOffsetRef.current); + nextOffset += prevNotificationPosition.get(firstKey)?.y ?? 0; } - } else { - syncScrollOffset(scrollOffsetRef.current); } - prevKeyListRef.current = keyList; - prevNotificationPositionRef.current = new Map(notificationPosition); + syncScrollOffset(nextOffset); + prevRef.current = { keyList, notificationPosition }; }, [keyList, notificationPosition, syncScrollOffset]); React.useLayoutEffect(() => { @@ -81,82 +73,69 @@ export default function useListScroll( return; } - const resizeObserver = new ResizeObserver(() => { - syncScrollOffset(scrollOffsetRef.current); - }); + const resizeObserver = new ResizeObserver(() => syncScrollOffset(scrollOffsetRef.current)); resizeObserver.observe(viewportNode); resizeObserver.observe(contentNode); - return () => { - resizeObserver.disconnect(); - }; + return () => resizeObserver.disconnect(); }, [syncScrollOffset]); React.useEffect(() => { - if (!expanded) { - touchStartYRef.current = null; - touchStartOffsetRef.current = 0; - syncScrollOffset(0); + if (expanded) { + return; } + + touchStartRef.current = null; + syncScrollOffset(0); }, [expanded, syncScrollOffset]); + /** + * Updates the list offset from mouse wheel input. + */ const onWheel = React.useCallback( (event: React.WheelEvent) => { - const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); - - if (!maxScroll) { + if (!getMaxScroll(viewportRef.current, contentRef.current)) { return; } event.preventDefault(); - - const nextOffset = clampScrollOffset(scrollOffsetRef.current - event.deltaY, maxScroll); - - if (nextOffset !== scrollOffsetRef.current) { - syncScrollOffset(nextOffset); - } + syncScrollOffset(scrollOffsetRef.current - event.deltaY); }, [syncScrollOffset], ); + /** + * Stores the touch start position and current offset. + */ const onTouchStart = React.useCallback((event: React.TouchEvent) => { const touch = event.touches[0]; - - if (!touch) { - return; - } - - touchStartYRef.current = touch.clientY; - touchStartOffsetRef.current = scrollOffsetRef.current; + touchStartRef.current = touch ? { y: touch.clientY, offset: scrollOffsetRef.current } : null; }, []); + /** + * Updates the list offset while the user is dragging. + */ const onTouchMove = React.useCallback( (event: React.TouchEvent) => { const touch = event.touches[0]; - const touchStartY = touchStartYRef.current; - const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); + const touchStart = touchStartRef.current; - if (!touch || touchStartY === null || !maxScroll) { + if (!touch || !touchStart || !getMaxScroll(viewportRef.current, contentRef.current)) { return; } event.preventDefault(); - - const nextOffset = clampScrollOffset( - touchStartOffsetRef.current + touch.clientY - touchStartY, - maxScroll, - ); - - if (nextOffset !== scrollOffsetRef.current) { - syncScrollOffset(nextOffset); - } + syncScrollOffset(touchStart.offset + touch.clientY - touchStart.y); }, [syncScrollOffset], ); + /** + * Clears the active touch scroll state. + */ const onTouchEnd = React.useCallback(() => { - touchStartYRef.current = null; + touchStartRef.current = null; }, []); return { From fe6c8c5a7b01b3cfb14d2a93008a23d6a7d77d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 9 Apr 2026 18:22:51 +0800 Subject: [PATCH 43/76] refactor: replace custom scroll with native CSS scrolling - Remove useListScroll hook entirely - Use native browser scrolling via overflow-y: auto - Hide scrollbars with CSS for cleaner appearance - Return totalHeight from useListPosition for content sizing - Simplify NotificationList by removing manual scroll handling Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 14 +++ assets/index.less | 17 ++++ src/NotificationList.tsx | 17 ++-- src/hooks/useListPosition/index.ts | 10 +- src/hooks/useListScroll.ts | 150 ----------------------------- 5 files changed, 45 insertions(+), 163 deletions(-) delete mode 100644 src/hooks/useListScroll.ts diff --git a/assets/geek.less b/assets/geek.less index 74b3e50..f20185c 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -17,6 +17,20 @@ overflow: hidden; overscroll-behavior: contain; + &-list { + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + } + &-list-content { position: relative; display: flex; diff --git a/assets/index.less b/assets/index.less index 3011182..c6c60ba 100644 --- a/assets/index.less +++ b/assets/index.less @@ -16,6 +16,19 @@ pointer-events: none; flex-direction: column; + &-list { + overflow-x: hidden; + overflow-y: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } + } + // Position &-top, &-topLeft, @@ -126,6 +139,10 @@ } } + &-list-content { + position: relative; + } + &-fade { overflow: hidden; transition: all 0.3s; diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 07d31a8..62173fa 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -11,7 +11,6 @@ import Notification, { type NotificationStyles, } from './Notification'; import useListPosition from './hooks/useListPosition'; -import useListScroll from './hooks/useListScroll'; import useStack from './hooks/useStack'; import { composeRef } from '@rc-component/util/lib/ref'; @@ -89,9 +88,12 @@ const NotificationList: React.FC = (props) => { }, [expanded, offset, stackEnabled, threshold]); const [gap, setGap] = React.useState(0); - const [notificationPosition, setNodeSize] = useListPosition(configList, stackPosition, gap); - const { contentRef, onTouchEnd, onTouchMove, onTouchStart, onWheel, scrollOffset, viewportRef } = - useListScroll(keyList, notificationPosition, expanded); + const contentRef = React.useRef(null); + const [notificationPosition, setNodeSize, totalHeight] = useListPosition( + configList, + stackPosition, + gap, + ); React.useEffect(() => { const listNode = contentRef.current; @@ -122,23 +124,18 @@ const NotificationList: React.FC = (props) => { [`${prefixCls}-stack-expanded`]: expanded, }, )} - onTouchEnd={onTouchEnd} - onTouchMove={onTouchMove} - onTouchStart={onTouchStart} - onWheel={onWheel} onMouseEnter={() => { setListHovering(true); }} onMouseLeave={() => { setListHovering(false); }} - ref={viewportRef} style={style} >
diff --git a/src/hooks/useListPosition/index.ts b/src/hooks/useListPosition/index.ts index 1e03301..ba431ac 100644 --- a/src/hooks/useListPosition/index.ts +++ b/src/hooks/useListPosition/index.ts @@ -14,9 +14,10 @@ export default function useListPosition( ) { const [sizeMap, setNodeSize] = useSizes(); - const notificationPosition = React.useMemo(() => { + const [notificationPosition, totalHeight] = React.useMemo(() => { let offsetY = 0; let offsetBottom = 0; + let nextTotalHeight = 0; const nextNotificationPosition = new Map(); configList @@ -31,6 +32,9 @@ export default function useListPosition( }; nextNotificationPosition.set(key, nodePosition); + + nextTotalHeight = Math.max(nextTotalHeight, nodePosition.y + height); + if (stack) { offsetBottom = nodePosition.y + height; } else { @@ -38,8 +42,8 @@ export default function useListPosition( } }); - return nextNotificationPosition; + return [nextNotificationPosition, nextTotalHeight] as const; }, [configList, gap, sizeMap, stack]); - return [notificationPosition, setNodeSize] as const; + return [notificationPosition, setNodeSize, totalHeight] as const; } diff --git a/src/hooks/useListScroll.ts b/src/hooks/useListScroll.ts deleted file mode 100644 index b6faf0a..0000000 --- a/src/hooks/useListScroll.ts +++ /dev/null @@ -1,150 +0,0 @@ -import * as React from 'react'; -import type { NodePosition } from './useListPosition'; - -/** - * Measures how much the list content can scroll inside the viewport. - */ -function getMaxScroll(viewportNode: HTMLDivElement | null, contentNode: HTMLDivElement | null) { - if (!viewportNode) { - return 0; - } - - const { paddingBottom, paddingTop } = window.getComputedStyle(viewportNode); - const viewportHeight = - viewportNode.clientHeight - (parseFloat(paddingTop) || 0) - (parseFloat(paddingBottom) || 0); - - return Math.max((contentNode?.scrollHeight ?? 0) - viewportHeight, 0); -} - -/** - * Manages wheel and touch scrolling for the notification list. - */ -export default function useListScroll( - keyList: string[], - notificationPosition: Map, - expanded = false, -) { - const viewportRef = React.useRef(null); - const contentRef = React.useRef(null); - const touchStartRef = React.useRef<{ y: number; offset: number } | null>(null); - const prevRef = React.useRef({ keyList, notificationPosition }); - const scrollOffsetRef = React.useRef(0); - const [scrollOffset, setScrollOffset] = React.useState(0); - - /** - * Applies the next offset and keeps it within the current scroll bounds. - */ - const syncScrollOffset = React.useCallback((nextOffset: number) => { - const maxScroll = getMaxScroll(viewportRef.current, contentRef.current); - // Clamp the offset so the content never scrolls past its visible range. - const mergedOffset = Math.max(-maxScroll, Math.min(0, nextOffset)); - - scrollOffsetRef.current = mergedOffset; - setScrollOffset(mergedOffset); - }, []); - - React.useLayoutEffect(() => { - const { keyList: prevKeyList, notificationPosition: prevNotificationPosition } = - prevRef.current; - let nextOffset = scrollOffsetRef.current; - - if (nextOffset < 0) { - const prevFirstKey = prevKeyList[0]; - const firstKey = keyList[0]; - const prependCount = prevFirstKey === undefined ? -1 : keyList.indexOf(prevFirstKey); - const removedCount = firstKey === undefined ? -1 : prevKeyList.indexOf(firstKey); - - if (prependCount > 0) { - nextOffset -= notificationPosition.get(prevFirstKey)?.y ?? 0; - } else if (removedCount > 0) { - nextOffset += prevNotificationPosition.get(firstKey)?.y ?? 0; - } - } - - syncScrollOffset(nextOffset); - prevRef.current = { keyList, notificationPosition }; - }, [keyList, notificationPosition, syncScrollOffset]); - - React.useLayoutEffect(() => { - const viewportNode = viewportRef.current; - const contentNode = contentRef.current; - - if (!viewportNode || !contentNode || typeof ResizeObserver === 'undefined') { - return; - } - - const resizeObserver = new ResizeObserver(() => syncScrollOffset(scrollOffsetRef.current)); - - resizeObserver.observe(viewportNode); - resizeObserver.observe(contentNode); - - return () => resizeObserver.disconnect(); - }, [syncScrollOffset]); - - React.useEffect(() => { - if (expanded) { - return; - } - - touchStartRef.current = null; - syncScrollOffset(0); - }, [expanded, syncScrollOffset]); - - /** - * Updates the list offset from mouse wheel input. - */ - const onWheel = React.useCallback( - (event: React.WheelEvent) => { - if (!getMaxScroll(viewportRef.current, contentRef.current)) { - return; - } - - event.preventDefault(); - syncScrollOffset(scrollOffsetRef.current - event.deltaY); - }, - [syncScrollOffset], - ); - - /** - * Stores the touch start position and current offset. - */ - const onTouchStart = React.useCallback((event: React.TouchEvent) => { - const touch = event.touches[0]; - touchStartRef.current = touch ? { y: touch.clientY, offset: scrollOffsetRef.current } : null; - }, []); - - /** - * Updates the list offset while the user is dragging. - */ - const onTouchMove = React.useCallback( - (event: React.TouchEvent) => { - const touch = event.touches[0]; - const touchStart = touchStartRef.current; - - if (!touch || !touchStart || !getMaxScroll(viewportRef.current, contentRef.current)) { - return; - } - - event.preventDefault(); - syncScrollOffset(touchStart.offset + touch.clientY - touchStart.y); - }, - [syncScrollOffset], - ); - - /** - * Clears the active touch scroll state. - */ - const onTouchEnd = React.useCallback(() => { - touchStartRef.current = null; - }, []); - - return { - contentRef, - onTouchEnd, - onTouchMove, - onTouchStart, - onWheel, - scrollOffset, - viewportRef, - }; -} From 362793d729bda4cbddea72644e55a2ff2cc11bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 15:23:25 +0800 Subject: [PATCH 44/76] docs: add JSDoc comments to hooks and update examples - Add JSDoc documentation to useClosable, useListPosition, useSizes, useNoticeTimer, useNotification, and useStack - Update example files to use geek.less and inline motion config - Remove unused motion leave animations from hooks example Co-Authored-By: Claude Opus 4.6 --- docs/examples/context.tsx | 16 +++++++++++++--- docs/examples/hooks.tsx | 5 ----- docs/examples/maxCount.tsx | 17 ++++++++++++++--- docs/examples/showProgress.tsx | 17 ++++++++++++++--- src/hooks/useClosable.ts | 3 +++ src/hooks/useListPosition/index.ts | 3 +++ src/hooks/useListPosition/useSizes.ts | 3 +++ src/hooks/useNoticeTimer.ts | 4 ++++ src/hooks/useNotification.tsx | 4 ++++ src/hooks/useStack.ts | 3 +++ 10 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/examples/context.tsx b/docs/examples/context.tsx index 6401604..cd2c72a 100644 --- a/docs/examples/context.tsx +++ b/docs/examples/context.tsx @@ -1,8 +1,15 @@ /* eslint-disable no-console */ import React from 'react'; -import '../../assets/index.less'; +import type { CSSMotionProps } from '@rc-component/motion'; +import '../../assets/geek.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; const Context = React.createContext({ name: 'light' }); @@ -15,7 +22,10 @@ const NOTICE = { }; const Demo = () => { - const [{ open }, holder] = useNotification({ motion }); + const [{ open }, holder] = useNotification({ + motion, + prefixCls: 'notification', + }); return ( diff --git a/docs/examples/hooks.tsx b/docs/examples/hooks.tsx index 04782e2..ca6b171 100644 --- a/docs/examples/hooks.tsx +++ b/docs/examples/hooks.tsx @@ -9,11 +9,6 @@ const motion: CSSMotionProps = { motionAppear: true, motionEnter: true, motionLeave: true, - onLeaveStart: (ele) => { - const { offsetHeight } = ele; - return { height: offsetHeight }; - }, - onLeaveActive: () => ({ height: 0, opacity: 0, margin: 0 }), }; const App = () => { diff --git a/docs/examples/maxCount.tsx b/docs/examples/maxCount.tsx index 3bd6f32..de2211d 100644 --- a/docs/examples/maxCount.tsx +++ b/docs/examples/maxCount.tsx @@ -1,11 +1,22 @@ /* eslint-disable no-console */ import React from 'react'; -import '../../assets/index.less'; +import type { CSSMotionProps } from '@rc-component/motion'; +import '../../assets/geek.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; export default () => { - const [notice, contextHolder] = useNotification({ motion, maxCount: 3 }); + const [notice, contextHolder] = useNotification({ + motion, + maxCount: 3, + prefixCls: 'notification', + }); return ( <> diff --git a/docs/examples/showProgress.tsx b/docs/examples/showProgress.tsx index e45d582..ff91acf 100644 --- a/docs/examples/showProgress.tsx +++ b/docs/examples/showProgress.tsx @@ -1,11 +1,22 @@ /* eslint-disable no-console */ import React from 'react'; -import '../../assets/index.less'; +import type { CSSMotionProps } from '@rc-component/motion'; +import '../../assets/geek.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; export default () => { - const [notice, contextHolder] = useNotification({ motion, showProgress: true }); + const [notice, contextHolder] = useNotification({ + motion, + showProgress: true, + prefixCls: 'notification', + }); return ( <> diff --git a/src/hooks/useClosable.ts b/src/hooks/useClosable.ts index b2eb6de..56a72d9 100644 --- a/src/hooks/useClosable.ts +++ b/src/hooks/useClosable.ts @@ -12,6 +12,9 @@ export type ClosableType = boolean | ClosableConfig | null | undefined; export type ParsedClosableConfig = ClosableConfig & Required>; +/** + * Normalizes the closable option into a boolean flag, config, and aria props. + */ export default function useClosable( closable?: ClosableType, ): [boolean, ParsedClosableConfig, ReturnType] { diff --git a/src/hooks/useListPosition/index.ts b/src/hooks/useListPosition/index.ts index ba431ac..57c0811 100644 --- a/src/hooks/useListPosition/index.ts +++ b/src/hooks/useListPosition/index.ts @@ -7,6 +7,9 @@ export type NodePosition = { y: number; }; +/** + * Calculates each notification's position and the full list height. + */ export default function useListPosition( configList: { key: React.Key }[], stack?: StackConfig, diff --git a/src/hooks/useListPosition/useSizes.ts b/src/hooks/useListPosition/useSizes.ts index 57c361a..bf4a2a6 100644 --- a/src/hooks/useListPosition/useSizes.ts +++ b/src/hooks/useListPosition/useSizes.ts @@ -7,6 +7,9 @@ export type NodeSize = { export type NodeSizeMap = Record; +/** + * Stores measured node sizes by key and exposes a ref callback to update them. + */ export default function useSizes() { const [sizeMap, setSizeMap] = React.useState({}); diff --git a/src/hooks/useNoticeTimer.ts b/src/hooks/useNoticeTimer.ts index 000e027..6eeedb9 100644 --- a/src/hooks/useNoticeTimer.ts +++ b/src/hooks/useNoticeTimer.ts @@ -2,6 +2,10 @@ import * as React from 'react'; import raf from '@rc-component/util/es/raf'; import useEvent from '@rc-component/util/es/hooks/useEvent'; +/** + * Runs the notice auto-close timer and reports progress updates. + * Returns controls to pause and resume the timer. + */ export default function useNoticeTimer( duration: number | false | null, onClose: VoidFunction, diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx index d2f4d61..2f15bf1 100644 --- a/src/hooks/useNotification.tsx +++ b/src/hooks/useNotification.tsx @@ -69,6 +69,10 @@ function mergeConfig(...objList: Partial[]): T { return clone; } +/** + * Creates the notification API and the React holder element. + * Queueing is handled internally until the notification instance is ready. + */ export default function useNotification( rootConfig: NotificationConfig = {}, ): [NotificationAPI, React.ReactElement] { diff --git a/src/hooks/useStack.ts b/src/hooks/useStack.ts index 882a2f6..4d0f70b 100644 --- a/src/hooks/useStack.ts +++ b/src/hooks/useStack.ts @@ -7,6 +7,9 @@ type StackParams = Required; type UseStack = (config?: boolean | StackConfig) => [boolean, StackParams]; +/** + * Resolves the stack setting into an enabled flag and normalized stack params. + */ const useStack: UseStack = (config) => { const result: StackParams = { offset: DEFAULT_OFFSET, From 38196d81c57e4aee001dc1125dea8f34ec08890b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 15:59:26 +0800 Subject: [PATCH 45/76] refactor: use Less mixin for placement-specific styles - Add generate-placement mixin to reduce code duplication - Support topRight, bottomRight, topLeft, bottomLeft placements - Move motion and clip-path styles into placement-specific scopes - Remove hardcoded top/right positioning from container Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 130 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 39 deletions(-) diff --git a/assets/geek.less b/assets/geek.less index f20185c..3957309 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -4,10 +4,66 @@ @notificationMotionEase: cubic-bezier(0.22, 1, 0.36, 1); @notificationMotionOffset: 64px; +.generate-placement(@placement, @vertical, @horizontal, @contentMargin, @stackClip, @motionX) { + &-@{placement} { + @{vertical}: 0; + @{horizontal}: 0; + display: flex; + flex-direction: column; + + .@{notificationPrefixCls}-list-content { + @{contentMargin}: auto; + } + + .@{notificationPrefixCls}-notice { + @{vertical}: var(--notification-y, 0); + @{horizontal}: var(--notification-x, 0); + transform-origin: center bottom; + } + + .notification-fade-appear-prepare, + .notification-fade-enter-prepare { + opacity: 0; + transform: translateX(@motionX) scale(var(--notification-scale, 1)); + transition: none; + } + + .notification-fade-appear-start, + .notification-fade-enter-start { + opacity: 0; + transform: translateX(@motionX) scale(var(--notification-scale, 1)); + } + + .notification-fade-appear-active, + .notification-fade-enter-active { + opacity: 1; + transform: translateX(0) scale(var(--notification-scale, 1)); + } + + .notification-fade-leave-start { + opacity: 1; + transform: translateX(0) scale(var(--notification-scale, 1)); + } + + .notification-fade-leave-active { + opacity: 0; + transform: translateX(@motionX) scale(var(--notification-scale, 1)); + } + + &.@{notificationPrefixCls}-stack:not(.@{notificationPrefixCls}-stack-expanded) { + .@{notificationPrefixCls}-notice { + clip-path: @stackClip; + } + + .@{notificationPrefixCls}-notice[data-notification-index='0'] { + clip-path: inset(-50% -50% -50% -50%); + } + } + } +} + .@{notificationPrefixCls} { position: fixed; - top: 0; - right: 0; z-index: 1000; width: 360px; height: 100vh; @@ -17,6 +73,39 @@ overflow: hidden; overscroll-behavior: contain; + .generate-placement( + topRight, + top, + right, + margin-bottom, + inset(50% -50% -50% -50%), + @notificationMotionOffset + ); + .generate-placement( + bottomRight, + bottom, + right, + margin-top, + inset(-50% -50% 50% -50%), + @notificationMotionOffset + ); + .generate-placement( + topLeft, + top, + left, + margin-bottom, + inset(50% -50% -50% -50%), + -@notificationMotionOffset + ); + .generate-placement( + bottomLeft, + bottom, + left, + margin-top, + inset(-50% -50% 50% -50%), + -@notificationMotionOffset + ); + &-list { overflow-x: hidden; overflow-y: auto; @@ -64,15 +153,12 @@ box-sizing: border-box; width: 100%; // transform: translate3d(var(--notification-x, 0), var(--notification-y, 0), 0); - right: var(--notification-x, 0); - top: var(--notification-y, 0); transform: scale(var(--notification-scale, 1)); transition: transform @notificationMotionDuration @notificationMotionEase, inset @notificationMotionDuration @notificationMotionEase, clip-path @notificationMotionDuration @notificationMotionEase, opacity @notificationMotionDuration @notificationMotionEase; - transform-origin: center bottom; padding: 14px 16px; border: 2px solid #111; border-radius: 14px; @@ -123,11 +209,6 @@ &:not(.@{notificationPrefixCls}-stack-expanded) { .@{notificationPrefixCls}-notice { --notification-scale: ~'calc(1 - min(var(--notification-index, 0), 2) * 0.06)'; - clip-path: inset(50% -50% -50% -50%); - } - - .@{notificationPrefixCls}-notice[data-notification-index='0'] { - clip-path: inset(-50% -50% -50% -50%); } .@{notificationPrefixCls}-notice:not(.@{notificationPrefixCls}-notice-stack-in-threshold) { @@ -142,32 +223,3 @@ backface-visibility: hidden; will-change: transform, opacity; } - -.notification-fade-appear-prepare, -.notification-fade-enter-prepare { - opacity: 0; - transform: translateX(@notificationMotionOffset) scale(var(--notification-scale, 1)); - transition: none; -} - -.notification-fade-appear-start, -.notification-fade-enter-start { - opacity: 0; - transform: translateX(@notificationMotionOffset) scale(var(--notification-scale, 1)); -} - -.notification-fade-appear-active, -.notification-fade-enter-active { - opacity: 1; - transform: translateX(0) scale(var(--notification-scale, 1)); -} - -.notification-fade-leave-start { - opacity: 1; - transform: translateX(0) scale(var(--notification-scale, 1)); -} - -.notification-fade-leave-active { - opacity: 0; - transform: translateX(@notificationMotionOffset) scale(var(--notification-scale, 1)); -} From 5275ea4b887cb31077cea7e1f33511451ce92694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 16:02:14 +0800 Subject: [PATCH 46/76] fix: prevent list-content from shrinking in flex container Add flex-shrink: 0 to maintain stable content sizing. Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/geek.less b/assets/geek.less index 3957309..2e8880e 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -123,6 +123,7 @@ &-list-content { position: relative; display: flex; + flex-shrink: 0; flex-direction: column; gap: 8px; width: 100%; From c0c8351f9f336cbd146c298c74e0d7d9ee492869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 16:08:46 +0800 Subject: [PATCH 47/76] refactor: use flex-direction instead of content margin for placement Replace @contentMargin parameter with @flexDirection in placement mixin. Top positions use 'column', bottom positions use 'column-reverse'. Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/assets/geek.less b/assets/geek.less index 2e8880e..31db33b 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -4,16 +4,12 @@ @notificationMotionEase: cubic-bezier(0.22, 1, 0.36, 1); @notificationMotionOffset: 64px; -.generate-placement(@placement, @vertical, @horizontal, @contentMargin, @stackClip, @motionX) { +.generate-placement(@placement, @vertical, @horizontal, @flexDirection, @stackClip, @motionX) { &-@{placement} { @{vertical}: 0; @{horizontal}: 0; display: flex; - flex-direction: column; - - .@{notificationPrefixCls}-list-content { - @{contentMargin}: auto; - } + flex-direction: @flexDirection; .@{notificationPrefixCls}-notice { @{vertical}: var(--notification-y, 0); @@ -77,7 +73,7 @@ topRight, top, right, - margin-bottom, + column, inset(50% -50% -50% -50%), @notificationMotionOffset ); @@ -85,7 +81,7 @@ bottomRight, bottom, right, - margin-top, + column-reverse, inset(-50% -50% 50% -50%), @notificationMotionOffset ); @@ -93,7 +89,7 @@ topLeft, top, left, - margin-bottom, + column, inset(50% -50% -50% -50%), -@notificationMotionOffset ); @@ -101,7 +97,7 @@ bottomLeft, bottom, left, - margin-top, + column-reverse, inset(-50% -50% 50% -50%), -@notificationMotionOffset ); From bcae5bde304c031960982b68ec390d15fa264e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 16:28:53 +0800 Subject: [PATCH 48/76] feat: support configurable transform-origin per placement - Add @transformOrigin parameter to placement mixin - Top positions use 'center bottom' for transform origin - Bottom positions use 'center top' for transform origin Co-Authored-By: Claude Opus 4.6 --- assets/geek.less | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/assets/geek.less b/assets/geek.less index 31db33b..462fe3e 100644 --- a/assets/geek.less +++ b/assets/geek.less @@ -4,7 +4,15 @@ @notificationMotionEase: cubic-bezier(0.22, 1, 0.36, 1); @notificationMotionOffset: 64px; -.generate-placement(@placement, @vertical, @horizontal, @flexDirection, @stackClip, @motionX) { +.generate-placement( + @placement, + @vertical, + @horizontal, + @flexDirection, + @transformOrigin, + @stackClip, + @motionX +) { &-@{placement} { @{vertical}: 0; @{horizontal}: 0; @@ -14,7 +22,7 @@ .@{notificationPrefixCls}-notice { @{vertical}: var(--notification-y, 0); @{horizontal}: var(--notification-x, 0); - transform-origin: center bottom; + transform-origin: @transformOrigin; } .notification-fade-appear-prepare, @@ -74,6 +82,7 @@ top, right, column, + ~'center bottom', inset(50% -50% -50% -50%), @notificationMotionOffset ); @@ -82,6 +91,7 @@ bottom, right, column-reverse, + ~'center top', inset(-50% -50% 50% -50%), @notificationMotionOffset ); @@ -90,6 +100,7 @@ top, left, column, + ~'center bottom', inset(50% -50% -50% -50%), -@notificationMotionOffset ); @@ -98,6 +109,7 @@ bottom, left, column-reverse, + ~'center top', inset(-50% -50% 50% -50%), -@notificationMotionOffset ); From 8abf935783195582a25164122eac016944588267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 17:16:21 +0800 Subject: [PATCH 49/76] feat: merge wrapper classNames and styles from config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add wrapper className and style merging in NotificationList - Fix test assertions for wrapper class selectors - Fix close button text expectation (× instead of x) - Remove obsolete touch scroll tests after native scrolling refactor Co-Authored-By: Claude Opus 4.6 --- src/NotificationList.tsx | 5 ++ tests/hooks.test.tsx | 3 +- tests/index.test.tsx | 11 +++-- tests/stack.test.tsx | 98 ---------------------------------------- 4 files changed, 13 insertions(+), 104 deletions(-) diff --git a/src/NotificationList.tsx b/src/NotificationList.tsx index 62173fa..17dde42 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList.tsx @@ -172,6 +172,7 @@ const NotificationList: React.FC = (props) => { className={clsx(contextClassNames?.notice, config.className)} style={config.style} classNames={{ + wrapper: clsx(classNames?.wrapper, config.classNames?.wrapper), root: clsx(classNames?.root, config.classNames?.root, motionClassName), icon: clsx(classNames?.icon, config.classNames?.icon), section: clsx(classNames?.section, config.classNames?.section), @@ -179,6 +180,10 @@ const NotificationList: React.FC = (props) => { progress: clsx(classNames?.progress, config.classNames?.progress), }} styles={{ + wrapper: { + ...styles?.wrapper, + ...config.styles?.wrapper, + }, root: { ...styles?.root, ...config.styles?.root, diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 75ab88e..54f2918 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -194,13 +194,14 @@ describe('Notification.Hooks', () => { description:
, duration: 0, closable: true, + icon: , classNames: { root: 'notice-root', }, }); }); - expect(document.querySelector('.rc-notification-notice-wrapper')).toHaveClass('hook-wrapper'); + expect(document.querySelector('.hook-wrapper')).toHaveClass('hook-wrapper'); expect(document.querySelector('.rc-notification-notice')).toHaveClass('notice-root'); expect(document.querySelector('.rc-notification-notice-close')).toHaveClass('hook-close'); }); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index df15d95..bffb8ba 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -84,7 +84,7 @@ describe('Notification.Basic', () => { const closeBtn = document.querySelector('.rc-notification-notice-close'); expect(document.querySelectorAll('.test')).toHaveLength(1); - expect(closeBtn?.textContent).toEqual('x'); + expect(closeBtn?.textContent).toEqual('×'); expect(closeBtn).toHaveAttribute('aria-describedby', 'custom-close'); }); @@ -643,6 +643,7 @@ describe('Notification.Basic', () => { act(() => { instance.open({ + icon: , styles: { wrapper: { content: 'little', @@ -654,10 +655,10 @@ describe('Notification.Basic', () => { }); }); - expect(document.querySelector('.rc-notification-notice-wrapper')).toHaveStyle({ + expect(document.querySelector('.bamboo')).toHaveStyle({ content: 'little', }); - expect(document.querySelector('.rc-notification-notice-wrapper')).toHaveClass('bamboo'); + expect(document.querySelector('.bamboo')).toHaveClass('bamboo'); }); it('should className work', () => { @@ -896,7 +897,7 @@ describe('Notification.Basic', () => { }); }); - it('closes via keyboard Enter key', () => { + it('closes via close button click', () => { const { instance } = renderDemo(); let closeCount = 0; @@ -910,7 +911,7 @@ describe('Notification.Basic', () => { }); }); - fireEvent.keyDown(document.querySelector('.rc-notification-notice-close'), { key: 'Enter' }); // origin latest + fireEvent.click(document.querySelector('.rc-notification-notice-close')); // origin latest expect(closeCount).toEqual(1); }); diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 4e22f51..ab0f35b 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -230,102 +230,4 @@ describe('stack', () => { getComputedStyleSpy.mockRestore(); offsetHeightSpy.mockRestore(); }); - - it('supports touch scroll on mobile', () => { - const clientHeightSpy = vi - .spyOn(HTMLElement.prototype, 'clientHeight', 'get') - .mockImplementation(function mockClientHeight() { - if (this.classList?.contains('rc-notification-list')) { - return 120; - } - - return 0; - }); - const scrollHeightSpy = vi - .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') - .mockImplementation(function mockScrollHeight() { - if (this.classList?.contains('rc-notification-list-content')) { - return 300; - } - - return 0; - }); - - render( - ({ - key: index, - description: `Notice ${index}`, - duration: false, - }))} - />, - ); - - const list = document.querySelector('.rc-notification-list'); - const content = document.querySelector('.rc-notification-list-content'); - - fireEvent.touchStart(list!, { - touches: [{ clientY: 120 }], - }); - fireEvent.touchMove(list!, { - touches: [{ clientY: 60 }], - }); - - expect(content?.style.transform).toBe('translate3d(0, -60px, 0)'); - - fireEvent.touchEnd(list!); - - clientHeightSpy.mockRestore(); - scrollHeightSpy.mockRestore(); - }); - - it('resets scroll offset when stack collapses after hover leave', () => { - const clientHeightSpy = vi - .spyOn(HTMLElement.prototype, 'clientHeight', 'get') - .mockImplementation(function mockClientHeight() { - if (this.classList?.contains('rc-notification-list')) { - return 120; - } - - return 0; - }); - const scrollHeightSpy = vi - .spyOn(HTMLElement.prototype, 'scrollHeight', 'get') - .mockImplementation(function mockScrollHeight() { - if (this.classList?.contains('rc-notification-list-content')) { - return 300; - } - - return 0; - }); - - render( - ({ - key: index, - description: `Notice ${index}`, - duration: false, - }))} - />, - ); - - const list = document.querySelector('.rc-notification-list'); - const content = document.querySelector('.rc-notification-list-content'); - - fireEvent.mouseEnter(list!); - fireEvent.wheel(list!, { deltaY: 60 }); - - expect(content?.style.transform).toBe('translate3d(0, -60px, 0)'); - - fireEvent.mouseLeave(list!); - - expect(list).not.toHaveClass('rc-notification-stack-expanded'); - expect(content?.style.transform).toBe('translate3d(0, 0px, 0)'); - - clientHeightSpy.mockRestore(); - scrollHeightSpy.mockRestore(); - }); }); From 008de5b8d19eebb38ad962b401e8a07b2826b4b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 17:29:44 +0800 Subject: [PATCH 50/76] chore: upgrade eslint and @umijs/fabric - Upgrade eslint from ^7.8.1 to ^8.57.1 - Upgrade @umijs/fabric from ^2.0.0 to ^4.0.1 - Fix ESLint config to properly spread base config and rules Co-Authored-By: Claude Opus 4.6 --- .eslintrc.js | 6 +++++- package.json | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 33c6edd..dedbd89 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,11 +1,15 @@ +const base = require('@umijs/fabric/dist/eslint'); + module.exports = { - extends: [require.resolve('@umijs/fabric/dist/eslint')], + ...base, rules: { + ...base.rules, 'react/sort-comp': 0, 'react/require-default-props': 0, 'jsx-a11y/no-noninteractive-tabindex': 0, }, overrides: [ + ...(base.overrides || []), { // https://typescript-eslint.io/linting/troubleshooting/#i-get-errors-telling-me-eslint-was-configured-to-run--however-that-tsconfig-does-not--none-of-those-tsconfigs-include-this-file files: ['tests/*.test.tsx'], diff --git a/package.json b/package.json index d14071f..839e07e 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,10 @@ "@types/testing-library__jest-dom": "^6.0.0", "@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/parser": "^5.59.7", - "@umijs/fabric": "^2.0.0", + "@umijs/fabric": "^4.0.1", "@vitest/coverage-v8": "^0.34.2", "dumi": "^2.1.0", - "eslint": "^7.8.1", + "eslint": "^8.57.1", "father": "^4.0.0", "gh-pages": "^3.1.0", "husky": "^8.0.3", From 5875ecc853a5d414cb42783006ead88eb78159f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Apr 2026 17:38:01 +0800 Subject: [PATCH 51/76] chore: ignore temp skill files in gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d34b985..552c5b9 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ pnpm-lock.yaml .dumi/tmp-production bun.lockb + +.claude/skills/tmp-* From e6ec9d8cecfccd87861a759fab934fd9244a5945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Sun, 19 Apr 2026 16:22:26 +0800 Subject: [PATCH 52/76] refactor: export Notification component and NotificationProps type Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 70ac8f7..fc70e20 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,8 +3,15 @@ import Notice from './legacy/Notice'; import type { NotificationAPI, NotificationConfig } from './hooks/useNotification'; import NotificationProvider from './legacy/NotificationProvider'; import Progress from './Progress'; -import type { ComponentsType } from './Notification'; +import Notification from './Notification'; +import type { ComponentsType, NotificationProps } from './Notification'; import type { NotificationProgressProps } from './Progress'; -export { useNotification, Notice, NotificationProvider, Progress }; -export type { NotificationAPI, NotificationConfig, ComponentsType, NotificationProgressProps }; +export { useNotification, Notice, NotificationProvider, Progress, Notification }; +export type { + NotificationAPI, + NotificationConfig, + ComponentsType, + NotificationProps, + NotificationProgressProps, +}; From 5ddb565d8cf5c313206ed980f42504baf3bf5f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 20 Apr 2026 11:12:05 +0800 Subject: [PATCH 53/76] refactor NotificationList content --- src/NotificationList/Content.tsx | 39 +++++++++++++++++++ .../index.tsx} | 30 +++++++------- 2 files changed, 52 insertions(+), 17 deletions(-) create mode 100644 src/NotificationList/Content.tsx rename src/{NotificationList.tsx => NotificationList/index.tsx} (93%) diff --git a/src/NotificationList/Content.tsx b/src/NotificationList/Content.tsx new file mode 100644 index 0000000..700fd2f --- /dev/null +++ b/src/NotificationList/Content.tsx @@ -0,0 +1,39 @@ +import { clsx } from 'clsx'; +import * as React from 'react'; + +export interface ContentProps extends React.HTMLAttributes { + listPrefixCls: string; + height: number; +} + +const Content = React.forwardRef((props, ref) => { + const { listPrefixCls, height, className, style, ...restProps } = props; + + const contentPrefixCls = `${listPrefixCls}-content`; + + // ========================= Height ========================= + const prevHeightRef = React.useRef(height); + const prevHeight = prevHeightRef.current; + const heightStatus = height < prevHeight ? 'decrease' : 'increase'; + + prevHeightRef.current = height; + + // ========================= Render ========================= + return ( +
+ ); +}); + +if (process.env.NODE_ENV !== 'production') { + Content.displayName = 'NotificationListContent'; +} + +export default Content; diff --git a/src/NotificationList.tsx b/src/NotificationList/index.tsx similarity index 93% rename from src/NotificationList.tsx rename to src/NotificationList/index.tsx index 17dde42..60aaac7 100644 --- a/src/NotificationList.tsx +++ b/src/NotificationList/index.tsx @@ -1,21 +1,22 @@ import { CSSMotionList } from '@rc-component/motion'; import type { CSSMotionProps } from '@rc-component/motion'; +import { composeRef } from '@rc-component/util/lib/ref'; import { clsx } from 'clsx'; import * as React from 'react'; -import type { StackConfig } from './interface'; -import { NotificationContext } from './legacy/NotificationProvider'; +import useListPosition from '../hooks/useListPosition'; +import useStack from '../hooks/useStack'; +import type { StackConfig } from '../interface'; +import { NotificationContext } from '../legacy/NotificationProvider'; import Notification, { type ComponentsType, type NotificationClassNames, type NotificationProps, type NotificationStyles, -} from './Notification'; -import useListPosition from './hooks/useListPosition'; -import useStack from './hooks/useStack'; -import { composeRef } from '@rc-component/util/lib/ref'; +} from '../Notification'; +import Content from './Content'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; -export type { StackConfig } from './interface'; +export type { StackConfig } from '../interface'; export interface NotificationListConfig extends Omit { key: React.Key; @@ -94,6 +95,7 @@ const NotificationList: React.FC = (props) => { stackPosition, gap, ); + const hasConfigList = !!configList.length; React.useEffect(() => { const listNode = contentRef.current; @@ -106,7 +108,7 @@ const NotificationList: React.FC = (props) => { const nextGap = parseFloat(rowGap || cssGap) || 0; setGap((prevGap) => (prevGap === nextGap ? prevGap : nextGap)); - }, [!!configList.length]); + }, [hasConfigList]); // ========================= Render ========================= const listPrefixCls = `${prefixCls}-list`; @@ -132,13 +134,7 @@ const NotificationList: React.FC = (props) => { }} style={style} > -
+ = (props) => { ); }} -
+
); }; export default NotificationList; -export type { NotificationClassNames, NotificationStyles } from './Notification'; +export type { NotificationClassNames, NotificationStyles } from '../Notification'; From fc6007373fe65e33d2063ef43ae7cdd549d39799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 23 Apr 2026 13:35:55 +0800 Subject: [PATCH 54/76] fix: only mark hovered stacked notifications expanded --- src/NotificationList/index.tsx | 4 +++- tests/stack.test.tsx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index 60aaac7..0010ac4 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -76,7 +76,9 @@ const NotificationList: React.FC = (props) => { const placementMotion = typeof motion === 'function' ? motion(placement) : motion; const [stackEnabled, { offset, threshold }] = useStack(stackConfig); const [listHovering, setListHovering] = React.useState(false); + const stackCollapsed = stackEnabled && keys.length > threshold; const expanded = stackEnabled && (listHovering || keys.length <= threshold); + const stackExpanded = stackCollapsed && listHovering; const stackPosition = React.useMemo(() => { if (!stackEnabled || expanded) { return undefined; @@ -123,7 +125,7 @@ const NotificationList: React.FC = (props) => { className, { [`${prefixCls}-stack`]: stackEnabled, - [`${prefixCls}-stack-expanded`]: expanded, + [`${prefixCls}-stack-expanded`]: stackExpanded, }, )} onMouseEnter={() => { diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index ab0f35b..85a73b8 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -33,7 +33,11 @@ describe('stack', () => { } expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3); expect(document.querySelector('.rc-notification-stack')).toBeTruthy(); - expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); + expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); + + fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); + expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); + fireEvent.mouseLeave(document.querySelector('.rc-notification-list')); for (let i = 0; i < 2; i++) { fireEvent.click(container.querySelector('button')); From 0e512e3a72e20825272111857259e14242dc695f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 23 Apr 2026 15:20:44 +0800 Subject: [PATCH 55/76] Revert "fix: only mark hovered stacked notifications expanded" This reverts commit fc6007373fe65e33d2063ef43ae7cdd549d39799. --- src/NotificationList/index.tsx | 4 +--- tests/stack.test.tsx | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index 0010ac4..60aaac7 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -76,9 +76,7 @@ const NotificationList: React.FC = (props) => { const placementMotion = typeof motion === 'function' ? motion(placement) : motion; const [stackEnabled, { offset, threshold }] = useStack(stackConfig); const [listHovering, setListHovering] = React.useState(false); - const stackCollapsed = stackEnabled && keys.length > threshold; const expanded = stackEnabled && (listHovering || keys.length <= threshold); - const stackExpanded = stackCollapsed && listHovering; const stackPosition = React.useMemo(() => { if (!stackEnabled || expanded) { return undefined; @@ -125,7 +123,7 @@ const NotificationList: React.FC = (props) => { className, { [`${prefixCls}-stack`]: stackEnabled, - [`${prefixCls}-stack-expanded`]: stackExpanded, + [`${prefixCls}-stack-expanded`]: expanded, }, )} onMouseEnter={() => { diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 85a73b8..ab0f35b 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -33,11 +33,7 @@ describe('stack', () => { } expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3); expect(document.querySelector('.rc-notification-stack')).toBeTruthy(); - expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); - - fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); - expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); - fireEvent.mouseLeave(document.querySelector('.rc-notification-list')); + expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); for (let i = 0; i < 2; i++) { fireEvent.click(container.querySelector('button')); From c23ba9db83cd5c9550dd6385e1f9946e3bc04365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Thu, 23 Apr 2026 16:24:55 +0800 Subject: [PATCH 56/76] fix: refine notification stack hover and height --- src/NotificationList/index.tsx | 1 + src/hooks/useListPosition/index.ts | 10 ++++++---- tests/stack.test.tsx | 7 +++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index 60aaac7..63593ce 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -124,6 +124,7 @@ const NotificationList: React.FC = (props) => { { [`${prefixCls}-stack`]: stackEnabled, [`${prefixCls}-stack-expanded`]: expanded, + [`${listPrefixCls}-hovered`]: listHovering, }, )} onMouseEnter={() => { diff --git a/src/hooks/useListPosition/index.ts b/src/hooks/useListPosition/index.ts index 57c0811..2f10221 100644 --- a/src/hooks/useListPosition/index.ts +++ b/src/hooks/useListPosition/index.ts @@ -19,8 +19,8 @@ export default function useListPosition( const [notificationPosition, totalHeight] = React.useMemo(() => { let offsetY = 0; - let offsetBottom = 0; let nextTotalHeight = 0; + const stackThreshold = stack?.threshold ?? 0; const nextNotificationPosition = new Map(); configList @@ -31,15 +31,17 @@ export default function useListPosition( const height = sizeMap[key]?.height ?? 0; const nodePosition = { x: 0, - y: stack && index > 0 ? offsetBottom + (stack.offset ?? 0) - height : offsetY, + y: stack && index > 0 ? offsetY + (stack.offset ?? 0) - height : offsetY, }; nextNotificationPosition.set(key, nodePosition); - nextTotalHeight = Math.max(nextTotalHeight, nodePosition.y + height); + if (!stack || index < stackThreshold) { + nextTotalHeight = Math.max(nextTotalHeight, nodePosition.y + height); + } if (stack) { - offsetBottom = nodePosition.y + height; + offsetY = nodePosition.y + height; } else { offsetY += height + gap; } diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index ab0f35b..7efe7b2 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -62,6 +62,10 @@ describe('stack', () => { fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); + expect(document.querySelector('.rc-notification-list-hovered')).toBeTruthy(); + + fireEvent.mouseLeave(document.querySelector('.rc-notification-list')); + expect(document.querySelector('.rc-notification-list-hovered')).toBeFalsy(); }); it('should collapse when amount is less than threshold', () => { @@ -147,17 +151,20 @@ describe('stack', () => { const secondNotice = document .querySelector('.context-content-second') ?.closest('.rc-notification-notice'); + const contentNode = document.querySelector('.rc-notification-list-content'); const getBottom = (notice: HTMLElement | undefined | null) => (notice ? parseFloat(notice.style.getPropertyValue('--notification-y')) : 0) + (notice?.offsetHeight ?? 0); + expect(contentNode?.style.height).toBe('40px'); expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('-28px'); expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); expect(getBottom(firstNotice) - getBottom(secondNotice)).toBe(12); fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); + expect(contentNode?.style.height).toBe('120px'); expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('40px'); expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); From 484b9959cb422490348215c96e23aea7ae0aca5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 24 Apr 2026 11:53:49 +0800 Subject: [PATCH 57/76] refactor: remove legacy notification code --- src/NotificationList/index.tsx | 2 +- src/{legacy => }/NotificationProvider.tsx | 11 +- src/index.ts | 5 +- src/legacy/Notice.tsx | 166 ------------------- src/legacy/NoticeList.tsx | 188 ---------------------- src/legacy/Notifications.tsx | 178 -------------------- src/legacy/hooks/useNotification.tsx | 176 -------------------- src/legacy/hooks/useStack.ts | 25 --- src/legacy/interface.ts | 59 ------- 9 files changed, 8 insertions(+), 802 deletions(-) rename src/{legacy => }/NotificationProvider.tsx (51%) delete mode 100644 src/legacy/Notice.tsx delete mode 100644 src/legacy/NoticeList.tsx delete mode 100644 src/legacy/Notifications.tsx delete mode 100644 src/legacy/hooks/useNotification.tsx delete mode 100644 src/legacy/hooks/useStack.ts delete mode 100644 src/legacy/interface.ts diff --git a/src/NotificationList/index.tsx b/src/NotificationList/index.tsx index 63593ce..d12da41 100644 --- a/src/NotificationList/index.tsx +++ b/src/NotificationList/index.tsx @@ -6,13 +6,13 @@ import * as React from 'react'; import useListPosition from '../hooks/useListPosition'; import useStack from '../hooks/useStack'; import type { StackConfig } from '../interface'; -import { NotificationContext } from '../legacy/NotificationProvider'; import Notification, { type ComponentsType, type NotificationClassNames, type NotificationProps, type NotificationStyles, } from '../Notification'; +import { NotificationContext } from '../NotificationProvider'; import Content from './Content'; export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; diff --git a/src/legacy/NotificationProvider.tsx b/src/NotificationProvider.tsx similarity index 51% rename from src/legacy/NotificationProvider.tsx rename to src/NotificationProvider.tsx index 798b7e7..663f207 100644 --- a/src/legacy/NotificationProvider.tsx +++ b/src/NotificationProvider.tsx @@ -1,5 +1,4 @@ -import type { FC } from 'react'; -import React from 'react'; +import * as React from 'react'; export interface NotificationContextProps { classNames?: { @@ -14,10 +13,10 @@ export interface NotificationProviderProps extends NotificationContextProps { children: React.ReactNode; } -const NotificationProvider: FC = ({ children, classNames }) => { - return ( - {children} - ); +const NotificationProvider: React.FC = ({ children, classNames }) => { + const context = React.useMemo(() => ({ classNames }), [classNames]); + + return {children}; }; export default NotificationProvider; diff --git a/src/index.ts b/src/index.ts index fc70e20..3df7228 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,12 @@ import useNotification from './hooks/useNotification'; -import Notice from './legacy/Notice'; import type { NotificationAPI, NotificationConfig } from './hooks/useNotification'; -import NotificationProvider from './legacy/NotificationProvider'; +import NotificationProvider from './NotificationProvider'; import Progress from './Progress'; import Notification from './Notification'; import type { ComponentsType, NotificationProps } from './Notification'; import type { NotificationProgressProps } from './Progress'; -export { useNotification, Notice, NotificationProvider, Progress, Notification }; +export { useNotification, NotificationProvider, Progress, Notification }; export type { NotificationAPI, NotificationConfig, diff --git a/src/legacy/Notice.tsx b/src/legacy/Notice.tsx deleted file mode 100644 index 80c379a..0000000 --- a/src/legacy/Notice.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { clsx } from 'clsx'; -import KeyCode from '@rc-component/util/lib/KeyCode'; -import * as React from 'react'; -import type { NoticeConfig } from './interface'; -import pickAttrs from '@rc-component/util/lib/pickAttrs'; - -export interface NoticeProps extends Omit { - prefixCls: string; - className?: string; - style?: React.CSSProperties; - eventKey: React.Key; - - onClick?: React.MouseEventHandler; - onNoticeClose?: (key: React.Key) => void; - hovering?: boolean; -} - -const Notify = React.forwardRef((props, ref) => { - const { - prefixCls, - style, - className, - duration = 4.5, - showProgress, - pauseOnHover = true, - - eventKey, - content, - closable, - props: divProps, - - onClick, - onNoticeClose, - times, - hovering: forcedHovering, - } = props; - const [hovering, setHovering] = React.useState(false); - const [percent, setPercent] = React.useState(0); - const [spentTime, setSpentTime] = React.useState(0); - const mergedHovering = forcedHovering || hovering; - const mergedDuration: number = typeof duration === 'number' ? duration : 0; - const mergedShowProgress = mergedDuration > 0 && showProgress; - - // ======================== Close ========================= - const onInternalClose = () => { - onNoticeClose(eventKey); - }; - - const onCloseKeyDown: React.KeyboardEventHandler = (e) => { - if (e.key === 'Enter' || e.code === 'Enter' || e.keyCode === KeyCode.ENTER) { - onInternalClose(); - } - }; - - // ======================== Effect ======================== - React.useEffect(() => { - if (!mergedHovering && mergedDuration > 0) { - const start = Date.now() - spentTime; - const timeout = setTimeout( - () => { - onInternalClose(); - }, - mergedDuration * 1000 - spentTime, - ); - - return () => { - if (pauseOnHover) { - clearTimeout(timeout); - } - setSpentTime(Date.now() - start); - }; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mergedDuration, mergedHovering, times]); - - React.useEffect(() => { - if (!mergedHovering && mergedShowProgress && (pauseOnHover || spentTime === 0)) { - const start = performance.now(); - let animationFrame: number; - - const calculate = () => { - cancelAnimationFrame(animationFrame); - animationFrame = requestAnimationFrame((timestamp) => { - const runtime = timestamp + spentTime - start; - const progress = Math.min(runtime / (mergedDuration * 1000), 1); - setPercent(progress * 100); - if (progress < 1) { - calculate(); - } - }); - }; - - calculate(); - - return () => { - if (pauseOnHover) { - cancelAnimationFrame(animationFrame); - } - }; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mergedDuration, spentTime, mergedHovering, mergedShowProgress, times]); - - // ======================== Closable ======================== - const closableObj = React.useMemo(() => { - if (typeof closable === 'object' && closable !== null) { - return closable; - } - return {}; - }, [closable]); - - const ariaProps = pickAttrs(closableObj, true); - - // ======================== Progress ======================== - const validPercent = 100 - (!percent || percent < 0 ? 0 : percent > 100 ? 100 : percent); - - // ======================== Render ======================== - const noticePrefixCls = `${prefixCls}-notice`; - - return ( -
{ - setHovering(true); - divProps?.onMouseEnter?.(e); - }} - onMouseLeave={(e) => { - setHovering(false); - divProps?.onMouseLeave?.(e); - }} - onClick={onClick} - > - {/* Content */} -
{content}
- - {/* Close Icon */} - {closable && ( - - )} - - {/* Progress Bar */} - {mergedShowProgress && ( - - {validPercent + '%'} - - )} -
- ); -}); - -export default Notify; diff --git a/src/legacy/NoticeList.tsx b/src/legacy/NoticeList.tsx deleted file mode 100644 index 3cc797e..0000000 --- a/src/legacy/NoticeList.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import type { CSSProperties, FC } from 'react'; -import React, { useContext, useEffect, useRef, useState } from 'react'; -import { clsx } from 'clsx'; -import type { CSSMotionProps } from '@rc-component/motion'; -import { CSSMotionList } from '@rc-component/motion'; -import type { - InnerOpenConfig, - NoticeConfig, - OpenConfig, - Placement, - StackConfig, -} from './interface'; -import Notice from './Notice'; -import { NotificationContext } from './NotificationProvider'; -import useStack from './hooks/useStack'; - -export interface NoticeListProps { - configList?: OpenConfig[]; - placement?: Placement; - prefixCls?: string; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - stack?: StackConfig; - - // Events - onAllNoticeRemoved?: (placement: Placement) => void; - onNoticeClose?: (key: React.Key) => void; - - // Common - className?: string; - style?: CSSProperties; -} - -const NoticeList: FC = (props) => { - const { - configList, - placement, - prefixCls, - className, - style, - motion, - onAllNoticeRemoved, - onNoticeClose, - stack: stackConfig, - } = props; - - const { classNames: ctxCls } = useContext(NotificationContext); - - const dictRef = useRef>({}); - const [latestNotice, setLatestNotice] = useState(null); - const [hoverKeys, setHoverKeys] = useState([]); - - const keys = configList.map((config) => ({ - config, - key: String(config.key), - })); - - const [stack, { offset, threshold, gap }] = useStack(stackConfig); - - const expanded = stack && (hoverKeys.length > 0 || keys.length <= threshold); - - const placementMotion = typeof motion === 'function' ? motion(placement) : motion; - - // Clean hover key - useEffect(() => { - if (stack && hoverKeys.length > 1) { - setHoverKeys((prev) => - prev.filter((key) => keys.some(({ key: dataKey }) => key === dataKey)), - ); - } - }, [hoverKeys, keys, stack]); - - // Force update latest notice - useEffect(() => { - if (stack && dictRef.current[keys[keys.length - 1]?.key]) { - setLatestNotice(dictRef.current[keys[keys.length - 1]?.key]); - } - }, [keys, stack]); - - return ( - { - onAllNoticeRemoved(placement); - }} - > - {( - { config, className: motionClassName, style: motionStyle, index: motionIndex }, - nodeRef, - ) => { - const { key, times } = config as InnerOpenConfig; - const strKey = String(key); - const { - className: configClassName, - style: configStyle, - classNames: configClassNames, - styles: configStyles, - ...restConfig - } = config as NoticeConfig; - const dataIndex = keys.findIndex((item) => item.key === strKey); - - // If dataIndex is -1, that means this notice has been removed in data, but still in dom - // Should minus (motionIndex - 1) to get the correct index because keys.length is not the same as dom length - const stackStyle: CSSProperties = {}; - if (stack) { - const index = keys.length - 1 - (dataIndex > -1 ? dataIndex : motionIndex - 1); - const transformX = placement === 'top' || placement === 'bottom' ? '-50%' : '0'; - if (index > 0) { - stackStyle.height = expanded - ? dictRef.current[strKey]?.offsetHeight - : latestNotice?.offsetHeight; - - // Transform - let verticalOffset = 0; - for (let i = 0; i < index; i++) { - verticalOffset += dictRef.current[keys[keys.length - 1 - i].key]?.offsetHeight + gap; - } - - const transformY = - (expanded ? verticalOffset : index * offset) * (placement.startsWith('top') ? 1 : -1); - const scaleX = - !expanded && latestNotice?.offsetWidth && dictRef.current[strKey]?.offsetWidth - ? (latestNotice?.offsetWidth - offset * 2 * (index < 3 ? index : 3)) / - dictRef.current[strKey]?.offsetWidth - : 1; - stackStyle.transform = `translate3d(${transformX}, ${transformY}px, 0) scaleX(${scaleX})`; - } else { - stackStyle.transform = `translate3d(${transformX}, 0, 0)`; - } - } - - return ( -
- setHoverKeys((prev) => (prev.includes(strKey) ? prev : [...prev, strKey])) - } - onMouseLeave={() => setHoverKeys((prev) => prev.filter((k) => k !== strKey))} - > - { - if (dataIndex > -1) { - dictRef.current[strKey] = node; - } else { - delete dictRef.current[strKey]; - } - }} - prefixCls={prefixCls} - classNames={configClassNames} - styles={configStyles} - className={clsx(configClassName, ctxCls?.notice)} - style={configStyle} - times={times} - key={key} - eventKey={key} - onNoticeClose={onNoticeClose} - hovering={stack && hoverKeys.length > 0} - /> -
- ); - }} -
- ); -}; - -if (process.env.NODE_ENV !== 'production') { - NoticeList.displayName = 'NoticeList'; -} - -export default NoticeList; diff --git a/src/legacy/Notifications.tsx b/src/legacy/Notifications.tsx deleted file mode 100644 index 04f6847..0000000 --- a/src/legacy/Notifications.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import * as React from 'react'; -import type { ReactElement } from 'react'; -import { createPortal } from 'react-dom'; -import type { CSSMotionProps } from '@rc-component/motion'; -import type { InnerOpenConfig, OpenConfig, Placement, Placements, StackConfig } from './interface'; -import NoticeList from './NoticeList'; - -export interface NotificationsProps { - prefixCls?: string; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - container?: HTMLElement | ShadowRoot; - maxCount?: number; - className?: (placement: Placement) => string; - style?: (placement: Placement) => React.CSSProperties; - onAllRemoved?: VoidFunction; - stack?: StackConfig; - renderNotifications?: ( - node: ReactElement, - info: { prefixCls: string; key: React.Key }, - ) => ReactElement; -} - -export interface NotificationsRef { - open: (config: OpenConfig) => void; - close: (key: React.Key) => void; - destroy: () => void; -} - -// ant-notification ant-notification-topRight -const Notifications = React.forwardRef((props, ref) => { - const { - prefixCls = 'rc-notification', - container, - motion, - maxCount, - className, - style, - onAllRemoved, - stack, - renderNotifications, - } = props; - const [configList, setConfigList] = React.useState([]); - - // ======================== Close ========================= - const onNoticeClose = (key: React.Key) => { - // Trigger close event - const config = configList.find((item) => item.key === key); - const closable = config?.closable; - const closableObj = closable && typeof closable === 'object' ? closable : {}; - const { onClose: closableOnClose } = closableObj; - closableOnClose?.(); - config?.onClose?.(); - setConfigList((list) => list.filter((item) => item.key !== key)); - }; - - // ========================= Refs ========================= - React.useImperativeHandle(ref, () => ({ - open: (config) => { - setConfigList((list) => { - let clone = [...list]; - - // Replace if exist - const index = clone.findIndex((item) => item.key === config.key); - const innerConfig: InnerOpenConfig = { ...config }; - if (index >= 0) { - innerConfig.times = ((list[index] as InnerOpenConfig)?.times || 0) + 1; - clone[index] = innerConfig; - } else { - innerConfig.times = 0; - clone.push(innerConfig); - } - - if (maxCount > 0 && clone.length > maxCount) { - clone = clone.slice(-maxCount); - } - - return clone; - }); - }, - close: (key) => { - onNoticeClose(key); - }, - destroy: () => { - setConfigList([]); - }, - })); - - // ====================== Placements ====================== - const [placements, setPlacements] = React.useState({}); - - React.useEffect(() => { - const nextPlacements: Placements = {}; - - configList.forEach((config) => { - const { placement = 'topRight' } = config; - - if (placement) { - nextPlacements[placement] = nextPlacements[placement] || []; - nextPlacements[placement].push(config); - } - }); - - // Fill exist placements to avoid empty list causing remove without motion - Object.keys(placements).forEach((placement) => { - nextPlacements[placement] = nextPlacements[placement] || []; - }); - - setPlacements(nextPlacements); - }, [configList]); - - // Clean up container if all notices fade out - const onAllNoticeRemoved = (placement: Placement) => { - setPlacements((originPlacements) => { - const clone = { - ...originPlacements, - }; - const list = clone[placement] || []; - - if (!list.length) { - delete clone[placement]; - } - - return clone; - }); - }; - - // Effect tell that placements is empty now - const emptyRef = React.useRef(false); - React.useEffect(() => { - if (Object.keys(placements).length > 0) { - emptyRef.current = true; - } else if (emptyRef.current) { - // Trigger only when from exist to empty - onAllRemoved?.(); - emptyRef.current = false; - } - }, [placements]); - // ======================== Render ======================== - if (!container) { - return null; - } - - const placementList = Object.keys(placements) as Placement[]; - - return createPortal( - <> - {placementList.map((placement) => { - const placementConfigList = placements[placement]; - - const list = ( - - ); - - return renderNotifications - ? renderNotifications(list, { prefixCls, key: placement }) - : list; - })} - , - container, - ); -}); - -if (process.env.NODE_ENV !== 'production') { - Notifications.displayName = 'Notifications'; -} - -export default Notifications; diff --git a/src/legacy/hooks/useNotification.tsx b/src/legacy/hooks/useNotification.tsx deleted file mode 100644 index b5aed00..0000000 --- a/src/legacy/hooks/useNotification.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import type { CSSMotionProps } from '@rc-component/motion'; -import * as React from 'react'; -import type { NotificationsProps, NotificationsRef } from '../Notifications'; -import Notifications from '../Notifications'; -import type { OpenConfig, Placement, StackConfig } from '../interface'; -import { useEvent } from '@rc-component/util'; - -const defaultGetContainer = () => document.body; - -type OptionalConfig = Partial; - -export interface NotificationConfig { - prefixCls?: string; - /** Customize container. It will repeat call which means you should return same container element. */ - getContainer?: () => HTMLElement | ShadowRoot; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - - closable?: - | boolean - | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); - maxCount?: number; - duration?: number | false | null; - showProgress?: boolean; - pauseOnHover?: boolean; - /** @private. Config for notification holder style. Safe to remove if refactor */ - className?: (placement: Placement) => string; - /** @private. Config for notification holder style. Safe to remove if refactor */ - style?: (placement: Placement) => React.CSSProperties; - /** @private Trigger when all the notification closed. */ - onAllRemoved?: VoidFunction; - stack?: StackConfig; - /** @private Slot for style in Notifications */ - renderNotifications?: NotificationsProps['renderNotifications']; -} - -export interface NotificationAPI { - open: (config: OptionalConfig) => void; - close: (key: React.Key) => void; - destroy: () => void; -} - -interface OpenTask { - type: 'open'; - config: OpenConfig; -} - -interface CloseTask { - type: 'close'; - key: React.Key; -} - -interface DestroyTask { - type: 'destroy'; -} - -type Task = OpenTask | CloseTask | DestroyTask; - -let uniqueKey = 0; - -function mergeConfig(...objList: Partial[]): T { - const clone: T = {} as T; - - objList.forEach((obj) => { - if (obj) { - Object.keys(obj).forEach((key) => { - const val = obj[key]; - - if (val !== undefined) { - clone[key] = val; - } - }); - } - }); - - return clone; -} - -export default function useNotification( - rootConfig: NotificationConfig = {}, -): [NotificationAPI, React.ReactElement] { - const { - getContainer = defaultGetContainer, - motion, - prefixCls, - maxCount, - className, - style, - onAllRemoved, - stack, - renderNotifications, - ...shareConfig - } = rootConfig; - - const [container, setContainer] = React.useState(); - const notificationsRef = React.useRef(); - const contextHolder = ( - - ); - - const [taskQueue, setTaskQueue] = React.useState([]); - - const open = useEvent((config) => { - const mergedConfig = mergeConfig(shareConfig, config); - if (mergedConfig.key === null || mergedConfig.key === undefined) { - mergedConfig.key = `rc-notification-${uniqueKey}`; - uniqueKey += 1; - } - - setTaskQueue((queue) => [...queue, { type: 'open', config: mergedConfig }]); - }); - - // ========================= Refs ========================= - const api = React.useMemo( - () => ({ - open: open, - close: (key) => { - setTaskQueue((queue) => [...queue, { type: 'close', key }]); - }, - destroy: () => { - setTaskQueue((queue) => [...queue, { type: 'destroy' }]); - }, - }), - [], - ); - - // ======================= Container ====================== - // `getContainer` should be stable. - React.useEffect(() => { - setContainer(getContainer()); - }); - - // ======================== Effect ======================== - React.useEffect(() => { - // Flush queued tasks once the holder is ready. - if (notificationsRef.current && taskQueue.length) { - taskQueue.forEach((task) => { - switch (task.type) { - case 'open': - notificationsRef.current.open(task.config); - break; - - case 'close': - notificationsRef.current.close(task.key); - break; - - case 'destroy': - notificationsRef.current.destroy(); - break; - } - }); - - // https://github.com/ant-design/ant-design/issues/52590 - // React `startTransition` will run once `useEffect` but many times `setState`, - // so we only publish a new queue when something was actually removed. - setTaskQueue((oriQueue) => { - const tgtTaskQueue = oriQueue.filter((task) => !taskQueue.includes(task)); - - return tgtTaskQueue.length === oriQueue.length ? oriQueue : tgtTaskQueue; - }); - } - }, [taskQueue]); - - // ======================== Return ======================== - return [api, contextHolder]; -} diff --git a/src/legacy/hooks/useStack.ts b/src/legacy/hooks/useStack.ts deleted file mode 100644 index 0ecf708..0000000 --- a/src/legacy/hooks/useStack.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { StackConfig } from '../interface'; - -const DEFAULT_OFFSET = 8; -const DEFAULT_THRESHOLD = 3; -const DEFAULT_GAP = 16; - -type StackParams = Exclude; - -type UseStack = (config?: StackConfig) => [boolean, StackParams]; - -const useStack: UseStack = (config) => { - const result: StackParams = { - offset: DEFAULT_OFFSET, - threshold: DEFAULT_THRESHOLD, - gap: DEFAULT_GAP, - }; - if (config && typeof config === 'object') { - result.offset = config.offset ?? DEFAULT_OFFSET; - result.threshold = config.threshold ?? DEFAULT_THRESHOLD; - result.gap = config.gap ?? DEFAULT_GAP; - } - return [!!config, result]; -}; - -export default useStack; diff --git a/src/legacy/interface.ts b/src/legacy/interface.ts deleted file mode 100644 index c50ddc1..0000000 --- a/src/legacy/interface.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type React from 'react'; - -export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; - -type NoticeSemanticProps = 'wrapper'; - -export interface NoticeConfig { - content?: React.ReactNode; - duration?: number | false | null; - showProgress?: boolean; - pauseOnHover?: boolean; - - closable?: - | boolean - | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); - className?: string; - style?: React.CSSProperties; - classNames?: { - [key in NoticeSemanticProps]?: string; - }; - styles?: { - [key in NoticeSemanticProps]?: React.CSSProperties; - }; - /** @private Internal usage. Do not override in your code */ - props?: React.HTMLAttributes & Record; - - onClose?: VoidFunction; - onClick?: React.MouseEventHandler; -} - -export interface OpenConfig extends NoticeConfig { - key: React.Key; - placement?: Placement; - content?: React.ReactNode; - duration?: number | false | null; -} - -export type InnerOpenConfig = OpenConfig & { times?: number }; - -export type Placements = Partial>; - -export type StackConfig = - | boolean - | { - /** - * When number is greater than threshold, notifications will be stacked together. - * @default 3 - */ - threshold?: number; - /** - * Offset when notifications are stacked together. - * @default 8 - */ - offset?: number; - /** - * Spacing between each notification when expanded. - */ - gap?: number; - }; From 93843baefa7b08e7fd14afa6aadfb99ab91b05ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Fri, 24 Apr 2026 11:58:25 +0800 Subject: [PATCH 58/76] test: align notification refactor coverage --- tests/hooks.test.tsx | 43 +-------- tests/index.test.tsx | 37 ++------ tests/stack.test.tsx | 153 ------------------------------ tests/useNoticeTimer.test.tsx | 174 ---------------------------------- 4 files changed, 8 insertions(+), 399 deletions(-) delete mode 100644 tests/useNoticeTimer.test.tsx diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 54f2918..8313514 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -1,7 +1,8 @@ import React, { ReactElement } from 'react'; import { render, fireEvent, act } from '@testing-library/react'; -import { useNotification, NotificationProvider } from '../src'; +import { useNotification } from '../src'; import type { NotificationAPI, NotificationConfig } from '../src'; +import NotificationProvider from '../src/NotificationProvider'; require('../assets/index.less'); @@ -180,44 +181,4 @@ describe('Notification.Hooks', () => { expect(document.querySelector('.rc-notification')).toHaveClass('banana'); expect(document.querySelector('.custom-notice')).toHaveClass('apple'); }); - - it('support root classNames defaults', () => { - const { instance } = renderDemo({ - classNames: { - wrapper: 'hook-wrapper', - close: 'hook-close', - }, - }); - - act(() => { - instance.open({ - description:
, - duration: 0, - closable: true, - icon: , - classNames: { - root: 'notice-root', - }, - }); - }); - - expect(document.querySelector('.hook-wrapper')).toHaveClass('hook-wrapper'); - expect(document.querySelector('.rc-notification-notice')).toHaveClass('notice-root'); - expect(document.querySelector('.rc-notification-notice-close')).toHaveClass('hook-close'); - }); - - it('support root placement defaults', () => { - const { instance } = renderDemo({ - placement: 'bottomLeft', - }); - - act(() => { - instance.open({ - description:
, - duration: 0, - }); - }); - - expect(document.querySelector('.rc-notification')).toHaveClass('rc-notification-bottomLeft'); - }); }); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index bffb8ba..13f3a18 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -68,26 +68,6 @@ describe('Notification.Basic', () => { expect(document.querySelector('.test-icon').textContent).toEqual('test-close-icon'); }); - it('works with default close icon and aria props', () => { - const { instance } = renderDemo(); - - act(() => { - instance.open({ - description:

1

, - closable: { - 'aria-describedby': 'custom-close', - }, - duration: 0, - }); - }); - - const closeBtn = document.querySelector('.rc-notification-notice-close'); - - expect(document.querySelectorAll('.test')).toHaveLength(1); - expect(closeBtn?.textContent).toEqual('×'); - expect(closeBtn).toHaveAttribute('aria-describedby', 'custom-close'); - }); - it('works with multi instance', () => { const { instance } = renderDemo(); @@ -944,12 +924,12 @@ describe('Notification.Basic', () => { it('show with progress', () => { const { instance } = renderDemo({ duration: 1, + showProgress: true, }); act(() => { instance.open({ description:

1

, - showProgress: true, }); }); @@ -969,30 +949,25 @@ describe('Notification.Basic', () => { }); it('supports custom progress component', () => { - const CustomProgress: React.FC = ({ className, percent }) => ( -
- {percent} -
+ const CustomProgress: React.FC = ({ className }) => ( + ); const { instance } = renderDemo({ - duration: 1, components: { progress: CustomProgress, }, + duration: 1, + showProgress: true, }); act(() => { instance.open({ description:

1

, - showProgress: true, }); }); - expect(document.querySelector('progress')).toBeFalsy(); - expect(document.querySelector('.rc-notification-notice-progress')?.textContent).toEqual( - '100', - ); + expect(document.querySelector('span.rc-notification-notice-progress')).toBeTruthy(); }); }); diff --git a/tests/stack.test.tsx b/tests/stack.test.tsx index 7efe7b2..4e6ba15 100644 --- a/tests/stack.test.tsx +++ b/tests/stack.test.tsx @@ -1,7 +1,6 @@ import { useNotification } from '../src'; import { fireEvent, render } from '@testing-library/react'; import React from 'react'; -import NotificationList from '../src/NotificationList'; require('../assets/index.less'); @@ -41,31 +40,8 @@ describe('stack', () => { expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5); expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); - const notices = Array.from(document.querySelectorAll('.rc-notification-notice')); - expect(notices.map((notice) => notice.getAttribute('data-notification-index'))).toEqual([ - '4', - '3', - '2', - '1', - '0', - ]); - expect( - notices - .slice(0, 2) - .every((notice) => !notice.matches('.rc-notification-notice-stack-in-threshold')), - ).toBeTruthy(); - expect( - notices - .slice(2) - .every((notice) => notice.matches('.rc-notification-notice-stack-in-threshold')), - ).toBeTruthy(); - fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy(); - expect(document.querySelector('.rc-notification-list-hovered')).toBeTruthy(); - - fireEvent.mouseLeave(document.querySelector('.rc-notification-list')); - expect(document.querySelector('.rc-notification-list-hovered')).toBeFalsy(); }); it('should collapse when amount is less than threshold', () => { @@ -108,133 +84,4 @@ describe('stack', () => { fireEvent.mouseLeave(document.querySelector('.rc-notification-list')); expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy(); }); - - it('passes stack offset to list position by bottom edge when collapsed', () => { - const offsetHeightSpy = vi - .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') - .mockImplementation(function mockOffsetHeight() { - if (this.classList?.contains('rc-notification-notice')) { - if (this.querySelector('.context-content-first')) { - return 80; - } - - if (this.querySelector('.context-content-second')) { - return 40; - } - } - - return 0; - }); - - render( - First
, - duration: false, - }, - { - key: 'second', - description:
Second
, - duration: false, - }, - ]} - />, - ); - - const firstNotice = document - .querySelector('.context-content-first') - ?.closest('.rc-notification-notice'); - const secondNotice = document - .querySelector('.context-content-second') - ?.closest('.rc-notification-notice'); - const contentNode = document.querySelector('.rc-notification-list-content'); - - const getBottom = (notice: HTMLElement | undefined | null) => - (notice ? parseFloat(notice.style.getPropertyValue('--notification-y')) : 0) + - (notice?.offsetHeight ?? 0); - - expect(contentNode?.style.height).toBe('40px'); - expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('-28px'); - expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); - expect(getBottom(firstNotice) - getBottom(secondNotice)).toBe(12); - - fireEvent.mouseEnter(document.querySelector('.rc-notification-list')); - - expect(contentNode?.style.height).toBe('120px'); - expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('40px'); - expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); - - offsetHeightSpy.mockRestore(); - }); - - it('passes list css gap to list position when expanded', () => { - const offsetHeightSpy = vi - .spyOn(HTMLElement.prototype, 'offsetHeight', 'get') - .mockImplementation(function mockOffsetHeight() { - if ( - this.classList?.contains('rc-notification-notice-wrapper') || - this.classList?.contains('rc-notification-notice') - ) { - return 50; - } - - return 0; - }); - const originGetComputedStyle = window.getComputedStyle; - const getComputedStyleSpy = vi - .spyOn(window, 'getComputedStyle') - .mockImplementation((element) => { - const style = originGetComputedStyle(element); - - if ((element as HTMLElement).classList?.contains('rc-notification-list-content')) { - return new Proxy(style, { - get(target, prop, receiver) { - if (prop === 'gap' || prop === 'rowGap') { - return '8px'; - } - - return Reflect.get(target, prop, receiver); - }, - }) as CSSStyleDeclaration; - } - - return style; - }); - - render( - First
, - duration: false, - }, - { - key: 'second', - description:
Second
, - duration: false, - }, - ]} - />, - ); - - const firstNotice = document - .querySelector('.context-content-first') - ?.closest('.rc-notification-notice'); - const secondNotice = document - .querySelector('.context-content-second') - ?.closest('.rc-notification-notice'); - - expect(firstNotice?.getAttribute('data-notification-index')).toBe('1'); - expect(secondNotice?.getAttribute('data-notification-index')).toBe('0'); - expect(firstNotice?.style.getPropertyValue('--notification-y')).toBe('58px'); - expect(secondNotice?.style.getPropertyValue('--notification-y')).toBe('0px'); - - getComputedStyleSpy.mockRestore(); - offsetHeightSpy.mockRestore(); - }); }); diff --git a/tests/useNoticeTimer.test.tsx b/tests/useNoticeTimer.test.tsx deleted file mode 100644 index be10817..0000000 --- a/tests/useNoticeTimer.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React, { act } from 'react'; -import { fireEvent, render } from '@testing-library/react'; -import useNoticeTimer from '../src/hooks/useNoticeTimer'; - -function TimerDemo({ - duration, - onClose, - onUpdate, -}: { - duration: number | false; - onClose: VoidFunction; - onUpdate: (ptg: number) => void; -}) { - const [onResume, onPause] = useNoticeTimer(duration, onClose, onUpdate); - - return ( - <> -