diff --git a/README.md b/README.md index bc059f5..43984c6 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ props details: 1.5 after duration of time, this notice will disappear.(seconds) + + showProgress + boolean + false + show with progress bar for auto-closing notification + style Object diff --git a/assets/index.less b/assets/index.less index 441ed46..5fbbf71 100644 --- a/assets/index.less +++ b/assets/index.less @@ -90,6 +90,34 @@ filter: alpha(opacity=100); } } + + // Progress + &-progress { + position: absolute; + left: 3px; + right: 3px; + border-radius: 1px; + overflow: hidden; + appearance: none; + -webkit-appearance: none; + display: block; + inline-size: 100%; + block-size: 2px; + border: 0; + + &, + &::-webkit-progress-bar { + background-color: rgba(0, 0, 0, 0.04); + } + + &::-moz-progress-bar { + background-color: #31afff; + } + + &::-webkit-progress-value { + background-color: #31afff; + } + } } &-fade { diff --git a/docs/demo/showProgress.md b/docs/demo/showProgress.md new file mode 100644 index 0000000..e895cd9 --- /dev/null +++ b/docs/demo/showProgress.md @@ -0,0 +1,8 @@ +--- +title: showProgress +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/showProgress.tsx b/docs/examples/showProgress.tsx new file mode 100644 index 0000000..50e80eb --- /dev/null +++ b/docs/examples/showProgress.tsx @@ -0,0 +1,24 @@ +/* eslint-disable no-console */ +import React from 'react'; +import '../../assets/index.less'; +import { useNotification } from '../../src'; +import motion from './motion'; + +export default () => { + const [notice, contextHolder] = useNotification({ motion, showProgress: true }); + + return ( + <> + + {contextHolder} + + ); +}; diff --git a/src/Notice.tsx b/src/Notice.tsx index 84adcb6..69bb940 100644 --- a/src/Notice.tsx +++ b/src/Notice.tsx @@ -21,6 +21,7 @@ const Notify = React.forwardRef 0 && showProgress; // ======================== Close ========================= const onInternalClose = () => { @@ -50,17 +54,48 @@ const Notify = React.forwardRef { if (!mergedHovering && duration > 0) { - const timeout = setTimeout(() => { - onInternalClose(); - }, duration * 1000); + const start = Date.now() - spentTime; + const timeout = setTimeout( + () => { + onInternalClose(); + }, + duration * 1000 - spentTime, + ); return () => { clearTimeout(timeout); + setSpentTime(Date.now() - start); }; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [duration, mergedHovering, times]); + React.useEffect(() => { + if (!mergedHovering && mergedShowProgress) { + const start = performance.now(); + let animationFrame: number; + + const calculate = () => { + cancelAnimationFrame(animationFrame); + animationFrame = requestAnimationFrame((timestamp) => { + const runtime = timestamp + spentTime - start; + const progress = Math.min(runtime / (duration * 1000), 1); + setPercent(progress * 100); + if (progress < 1) { + calculate(); + } + }); + }; + + calculate(); + + return () => { + cancelAnimationFrame(animationFrame); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [duration, mergedHovering, mergedShowProgress, times]); + // ======================== Closable ======================== const closableObj = React.useMemo(() => { if (typeof closable === 'object' && closable !== null) { @@ -74,6 +109,9 @@ const Notify = React.forwardRef 100 ? 100 : percent); + // ======================== Render ======================== const noticePrefixCls = `${prefixCls}-notice`; @@ -115,6 +153,13 @@ const Notify = React.forwardRef )} + + {/* Progress Bar */} + {mergedShowProgress && ( + + {validPercent + '%'} + + )} ); }); diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx index ad79905..31f5d81 100644 --- a/src/hooks/useNotification.tsx +++ b/src/hooks/useNotification.tsx @@ -17,6 +17,7 @@ export interface NotificationConfig { closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes); maxCount?: number; duration?: number; + showProgress?: 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 */ diff --git a/src/interface.ts b/src/interface.ts index b9de386..b5d48f1 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -7,6 +7,7 @@ type NoticeSemanticProps = 'wrapper'; export interface NoticeConfig { content?: React.ReactNode; duration?: number | null; + showProgress?: boolean; closeIcon?: React.ReactNode; closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes); className?: string; diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 6fa75ee..5dbbeb5 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -270,6 +270,45 @@ describe('Notification.Basic', () => { expect(document.querySelectorAll('.freeze')).toHaveLength(0); }); + it('continue timing after hover', () => { + const { instance } = renderDemo({ + duration: 1, + }); + + act(() => { + instance.open({ + content:

1

, + }); + }); + + expect(document.querySelector('.test')).toBeTruthy(); + + // Wait for 500ms + act(() => { + vi.advanceTimersByTime(500); + }); + expect(document.querySelector('.test')).toBeTruthy(); + + // Mouse in should not remove + fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(document.querySelector('.test')).toBeTruthy(); + + // Mouse out should not remove until 500ms later + fireEvent.mouseLeave(document.querySelector('.rc-notification-notice')); + act(() => { + vi.advanceTimersByTime(450); + }); + expect(document.querySelector('.test')).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(document.querySelector('.test')).toBeFalsy(); + }); + describe('maxCount', () => { it('remove work when maxCount set', () => { const { instance } = renderDemo({ @@ -688,6 +727,7 @@ describe('Notification.Basic', () => { fireEvent.keyDown(document.querySelector('.rc-notification-notice-close'), { key: 'Enter' }); // origin latest expect(closeCount).toEqual(1); }); + it('Support aria-* in closable', () => { const { instance } = renderDemo({ closable: { @@ -712,4 +752,33 @@ describe('Notification.Basic', () => { document.querySelector('.rc-notification-notice-close').getAttribute('aria-labelledby'), ).toEqual('close'); }); + + describe('showProgress', () => { + it('show with progress', () => { + const { instance } = renderDemo({ + duration: 1, + showProgress: true, + }); + + act(() => { + instance.open({ + content:

1

, + }); + }); + + expect(document.querySelector('.rc-notification-notice-progress')).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(document.querySelector('.rc-notification-notice-progress')).toBeTruthy(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(document.querySelector('.rc-notification-notice-progress')).toBeFalsy(); + }); + }); });