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/.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-* diff --git a/assets/index.less b/assets/index.less index eaa60f6..8f4b37b 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,115 +1,303 @@ @notificationPrefixCls: rc-notification; +@notificationMotionDuration: 0.3s; +@notificationMotionEase: cubic-bezier(0.22, 1, 0.36, 1); +@notificationMotionOffset: 64px; + +.generate-placement( + @placement, + @vertical, + @horizontal, + @flexDirection, + @transformOrigin, + @stackClip, + @motionX +) { + &-@{placement} { + @{vertical}: 0; + @{horizontal}: 0; + display: flex; + flex-direction: @flexDirection; + + .@{notificationPrefixCls}-notice { + @{vertical}: var(--notification-y, 0); + @{horizontal}: 0; + transform-origin: @transformOrigin; + } + + .@{notificationPrefixCls}-fade-appear-prepare, + .@{notificationPrefixCls}-fade-enter-prepare { + opacity: 0; + transform: translateX(@motionX) scale(var(--notification-scale, 1)); + transition: none; + } + + .@{notificationPrefixCls}-fade-appear-start, + .@{notificationPrefixCls}-fade-enter-start { + opacity: 0; + transform: translateX(@motionX) scale(var(--notification-scale, 1)); + } + + .@{notificationPrefixCls}-fade-appear-active, + .@{notificationPrefixCls}-fade-enter-active { + opacity: 1; + transform: translateX(0) scale(var(--notification-scale, 1)); + } + + .@{notificationPrefixCls}-fade-leave-start { + opacity: 1; + transform: translateX(0) scale(var(--notification-scale, 1)); + } + + .@{notificationPrefixCls}-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%); + } + } + } +} + +.generate-center-placement( + @placement, + @vertical, + @flexDirection, + @transformOrigin, + @stackClip, + @motionY +) { + &-@{placement} { + @{vertical}: 0; + left: 50%; + display: flex; + flex-direction: @flexDirection; + transform: translateX(-50%); + + .@{notificationPrefixCls}-notice { + @{vertical}: var(--notification-y, 0); + left: 0; + transform-origin: @transformOrigin; + } + + .@{notificationPrefixCls}-fade-appear-prepare, + .@{notificationPrefixCls}-fade-enter-prepare { + opacity: 0; + transform: translateY(@motionY) scale(var(--notification-scale, 1)); + transition: none; + } + + .@{notificationPrefixCls}-fade-appear-start, + .@{notificationPrefixCls}-fade-enter-start { + opacity: 0; + transform: translateY(@motionY) scale(var(--notification-scale, 1)); + } + + .@{notificationPrefixCls}-fade-appear-active, + .@{notificationPrefixCls}-fade-enter-active { + opacity: 1; + transform: translateY(0) scale(var(--notification-scale, 1)); + } + + .@{notificationPrefixCls}-fade-leave-start { + opacity: 1; + transform: translateY(0) scale(var(--notification-scale, 1)); + } + + .@{notificationPrefixCls}-fade-leave-active { + opacity: 0; + transform: translateY(@motionY) 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} { - // ====================== Notification ====================== position: fixed; z-index: 1000; - display: flex; - max-height: 100vh; - padding: 10px; - align-items: flex-end; - width: 340px; - overflow-x: hidden; - overflow-y: auto; + width: 360px; height: 100vh; + padding: 24px; box-sizing: border-box; pointer-events: none; - flex-direction: column; - - // Position - &-top, - &-topLeft, - &-topRight { - top: 0; + overflow: hidden; + overscroll-behavior: contain; + + .generate-placement( + topRight, + top, + right, + column, + ~'center bottom', + inset(50% -50% -50% -50%), + @notificationMotionOffset + ); + .generate-placement( + bottomRight, + bottom, + right, + column-reverse, + ~'center top', + inset(-50% -50% 50% -50%), + @notificationMotionOffset + ); + .generate-placement( + topLeft, + top, + left, + column, + ~'center bottom', + inset(50% -50% -50% -50%), + -@notificationMotionOffset + ); + .generate-placement( + bottomLeft, + bottom, + left, + column-reverse, + ~'center top', + inset(-50% -50% 50% -50%), + -@notificationMotionOffset + ); + .generate-center-placement( + top, + top, + column, + ~'center bottom', + inset(50% -50% -50% -50%), + -@notificationMotionOffset + ); + .generate-center-placement( + bottom, + bottom, + column-reverse, + ~'center top', + inset(-50% -50% 50% -50%), + @notificationMotionOffset + ); + + &-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; + } } - &-bottom, - &-bottomRight, - &-bottomLeft { - bottom: 0; + &-list-content { + position: relative; + display: flex; + flex-shrink: 0; + flex-direction: column; + gap: 8px; + width: 100%; + pointer-events: none; + transition: transform @notificationMotionDuration @notificationMotionEase; + will-change: transform; } - &-bottomRight, - &-topRight { - right: 0; + &-stack-expanded { + pointer-events: auto; + + .@{notificationPrefixCls}-list-content { + transition: none; + } } - // ========================= Notice ========================= &-notice { - position: relative; - display: block; + position: absolute; box-sizing: border-box; - line-height: 1.5; width: 100%; - - &-wrapper { - pointer-events: auto; - position: relative; - display: block; - box-sizing: border-box; - border-radius: 3px 3px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); - margin: 0 0 16px; - border: 1px solid #999; - border: 0px solid rgba(0, 0, 0, 0); - background: #fff; - width: 300px; - } - - // Content - &-content { - padding: 7px 20px 7px 10px; + padding: 14px 16px; + border: 2px solid #111; + border-radius: 14px; + background: linear-gradient(180deg, #ffffff 0%, #f4f9ff 100%); + box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.16); + color: #111; + font-size: 14px; + line-height: 1.5; + pointer-events: auto; + transform: scale(var(--notification-scale, 1)); + transition: + transform @notificationMotionDuration @notificationMotionEase, + inset @notificationMotionDuration @notificationMotionEase, + clip-path @notificationMotionDuration @notificationMotionEase, + opacity @notificationMotionDuration @notificationMotionEase; + + &-section { + padding-right: 36px; + white-space: pre-wrap; } - &-closable &-content { - padding-right: 20px; + &:not(&-closable) &-section { + padding-right: 0; } &-close { position: absolute; - top: 3px; - right: 5px; - color: #000; - font-weight: 700; - font-size: 16px; + 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; - text-decoration: none; - text-shadow: 0 1px 0 #fff; - outline: none; cursor: pointer; - opacity: 0.2; - filter: alpha(opacity=20); - border: 0; - background-color: #fff; - - &-x:after { - content: '×'; - } + transition: + transform @notificationMotionDuration @notificationMotionEase, + box-shadow @notificationMotionDuration @notificationMotionEase, + background-color @notificationMotionDuration @notificationMotionEase; &:hover { - text-decoration: none; - opacity: 1; - filter: alpha(opacity=100); + background: #f4f9ff; + box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.12); } } - // Progress &-progress { position: absolute; - left: 3px; - right: 3px; - border-radius: 1px; - overflow: hidden; - appearance: none; - -webkit-appearance: none; + right: 16px; + bottom: 8px; + left: 16px; display: block; - inline-size: 100%; + inline-size: auto; block-size: 2px; + overflow: hidden; border: 0; + border-radius: 1px; + appearance: none; + -webkit-appearance: none; &, &::-webkit-progress-bar { - background-color: rgba(0, 0, 0, 0.04); + background-color: rgba(0, 0, 0, 0.08); } &::-moz-progress-bar { @@ -122,144 +310,25 @@ } } - &-fade { - overflow: hidden; - transition: all 0.3s; - } - - &-fade-appear-prepare { - pointer-events: none; - opacity: 0 !important; - } - - &-fade-appear-start { - transform: translateX(100%); - opacity: 0; - } - - &-fade-appear-active { - transform: translateX(0); - opacity: 1; - } - - // .fade-effect() { - // animation-duration: 0.3s; - // animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); - // animation-fill-mode: both; - // } - - // &-fade-appear, - // &-fade-enter { - // opacity: 0; - // animation-play-state: paused; - // .fade-effect(); - // } - - // &-fade-leave { - // .fade-effect(); - // animation-play-state: paused; - // } - - // &-fade-appear&-fade-appear-active, - // &-fade-enter&-fade-enter-active { - // animation-name: rcNotificationFadeIn; - // animation-play-state: running; - // } - - // &-fade-leave&-fade-leave-active { - // animation-name: rcDialogFadeOut; - // animation-play-state: running; - // } - - // @keyframes rcNotificationFadeIn { - // 0% { - // opacity: 0; - // } - // 100% { - // opacity: 1; - // } - // } - - // @keyframes rcDialogFadeOut { - // 0% { - // opacity: 1; - // } - // 100% { - // opacity: 0; - // } - // } - - // ========================= Stack ========================= &-stack { - & > .@{notificationPrefixCls}-notice { - &-wrapper { - transition: all 0.3s; - position: absolute; - top: 12px; - opacity: 1; - - &:not(:nth-last-child(-n + 3)) { - opacity: 0; - right: 34px; - width: 252px; - overflow: hidden; - color: transparent; - pointer-events: none; - } - - &:nth-last-child(1) { - right: 10px; - } - - &:nth-last-child(2) { - right: 18px; - width: 284px; - color: transparent; - overflow: hidden; - } - - &:nth-last-child(3) { - right: 26px; - width: 268px; - color: transparent; - overflow: hidden; - } - } + .@{notificationPrefixCls}-notice { + clip-path: inset(-50% -50% -50% -50%); } - &&-expanded { - & > .@{notificationPrefixCls}-notice { - &-wrapper { - &:not(:nth-last-child(-n + 1)) { - opacity: 1; - width: 300px; - right: 10px; - overflow: unset; - color: inherit; - pointer-events: auto; - } - - &::before { - content: ""; - position: absolute; - left: 0; - right: 0; - top: -16px; - width: 100%; - height: calc(100% + 32px); - background: transparent; - pointer-events: auto; - color: rgb(0,0,0); - } - } + &:not(.@{notificationPrefixCls}-stack-expanded) { + .@{notificationPrefixCls}-notice { + --notification-scale: ~'calc(1 - min(var(--notification-index, 0), 2) * 0.06)'; } - } - &.@{notificationPrefixCls}-bottomRight { - & > .@{notificationPrefixCls}-notice-wrapper { - top: unset; - bottom: 12px; + .@{notificationPrefixCls}-notice:not(.@{notificationPrefixCls}-notice-stack-in-threshold) { + opacity: 0; + pointer-events: none; } } } } + +.@{notificationPrefixCls}-fade { + backface-visibility: hidden; + will-change: transform, opacity; +} diff --git a/docs/demo/NotificationList.md b/docs/demo/NotificationList.md new file mode 100644 index 0000000..468a7d9 --- /dev/null +++ b/docs/demo/NotificationList.md @@ -0,0 +1,8 @@ +--- +title: NotificationList +nav: + title: Demo + path: /demo +--- + + diff --git a/docs/examples/NotificationList.tsx b/docs/examples/NotificationList.tsx new file mode 100644 index 0000000..e30dbf1 --- /dev/null +++ b/docs/examples/NotificationList.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import type { CSSMotionProps } from '@rc-component/motion'; +import '../../assets/index.less'; +import NotificationList, { type NotificationListConfig } from '../../src/NotificationList'; + +const motion: CSSMotionProps = { + motionName: 'rc-notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; + +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, + description: `Config ${key + 1}`, + }), + [], + ); + + const createConfig = React.useCallback(() => { + const key = keyRef.current; + keyRef.current += 1; + + setConfigList((prevConfigList) => [...prevConfigList, createNotification(key)]); + }, [createNotification]); + + const createFiveConfigs = React.useCallback(() => { + setConfigList((prevConfigList) => { + const startKey = keyRef.current; + keyRef.current += 5; + + return [ + ...prevConfigList, + ...Array.from({ length: 5 }, (_, index) => createNotification(startKey + index)), + ]; + }); + }, [createNotification]); + + const removeLastConfig = React.useCallback(() => { + 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 ( + <> +
+ + + + + + +
+ + + + ); +}; + +export default Demo; diff --git a/docs/examples/context.tsx b/docs/examples/context.tsx index 9bcf104..9f135c5 100644 --- a/docs/examples/context.tsx +++ b/docs/examples/context.tsx @@ -1,13 +1,20 @@ /* eslint-disable no-console */ import React from 'react'; +import type { CSSMotionProps } from '@rc-component/motion'; import '../../assets/index.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'rc-notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; const Context = React.createContext({ name: 'light' }); const NOTICE = { - content: simple show, + description: simple show, onClose() { console.log('simple close'); }, @@ -15,7 +22,9 @@ const NOTICE = { }; const Demo = () => { - const [{ open }, holder] = useNotification({ motion }); + const [{ open }, holder] = useNotification({ + motion, + }); return ( @@ -24,7 +33,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 a9d3094..c76877f 100644 --- a/docs/examples/hooks.tsx +++ b/docs/examples/hooks.tsx @@ -1,21 +1,32 @@ /* eslint-disable no-console */ import React from 'react'; +import type { CSSMotionProps } from '@rc-component/motion'; import '../../assets/index.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'rc-notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; const App = () => { - const [notice, contextHolder] = useNotification({ motion, closable: true }); + const [notice, contextHolder] = useNotification({ + motion, + closable: true, + }); return ( <> -
-
+
+
{/* Default */} {/* Not Close */}
-
+
{/* No Closable */}
-
-
- {/* Destroy All */} - +
+ {/* Destroy All */} + +
{contextHolder} diff --git a/docs/examples/maxCount.tsx b/docs/examples/maxCount.tsx index 229d635..ee8def8 100644 --- a/docs/examples/maxCount.tsx +++ b/docs/examples/maxCount.tsx @@ -1,18 +1,28 @@ /* eslint-disable no-console */ import React from 'react'; +import type { CSSMotionProps } from '@rc-component/motion'; import '../../assets/index.less'; import { useNotification } from '../../src'; -import motion from './motion'; + +const motion: CSSMotionProps = { + motionName: 'rc-notification-fade', + motionAppear: true, + motionEnter: true, + motionLeave: true, +}; export default () => { - const [notice, contextHolder] = useNotification({ motion, maxCount: 3 }); + const [notice, contextHolder] = useNotification({ + motion, + maxCount: 3, + }); return ( <> - +
+ + +
{holder} ); diff --git a/package.json b/package.json index 737bc92..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", @@ -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/Notice.tsx b/src/Notice.tsx deleted file mode 100644 index 80c379a..0000000 --- a/src/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/NoticeList.tsx b/src/NoticeList.tsx deleted file mode 100644 index 3cc797e..0000000 --- a/src/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/Notification.tsx b/src/Notification.tsx new file mode 100644 index 0000000..7090d59 --- /dev/null +++ b/src/Notification.tsx @@ -0,0 +1,263 @@ +import * as React from 'react'; +import { clsx } from 'clsx'; +import useNoticeTimer from './hooks/useNoticeTimer'; +import { useEvent } from '@rc-component/util'; +import useClosable, { type ClosableType } from './hooks/useClosable'; +import DefaultProgress from './Progress'; +import type { NotificationProgressProps } from './Progress'; + +export interface NotificationClassNames { + wrapper?: string; + root?: string; + icon?: string; + section?: string; + close?: string; + progress?: string; +} + +export interface NotificationStyles { + wrapper?: React.CSSProperties; + root?: React.CSSProperties; + icon?: React.CSSProperties; + section?: React.CSSProperties; + close?: React.CSSProperties; + progress?: React.CSSProperties; +} + +export interface ComponentsType { + progress?: React.ComponentType; +} + +export interface NotificationProps { + // Style + prefixCls: string; + className?: string; + style?: React.CSSProperties; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + components?: ComponentsType; + + // UI + title?: React.ReactNode; + description?: React.ReactNode; + icon?: React.ReactNode; + actions?: React.ReactNode; + closable?: ClosableType; + offset?: number; + notificationIndex?: number; + stackInThreshold?: boolean; + props?: React.HTMLAttributes & Record; + + // Behavior + duration?: number | false | null; + showProgress?: boolean; + times?: number; + hovering?: boolean; + pauseOnHover?: boolean; + + // Function + onClick?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + /** @deprecated Please use `closable.onClose` instead. */ + onClose?: () => void; +} + +const Notification = React.forwardRef((props, ref) => { + const { + // Style + prefixCls, + className, + style, + classNames, + styles, + components, + + // UI + title, + description, + icon, + actions, + closable, + offset, + notificationIndex, + stackInThreshold, + props: rootProps, + + // Behavior + duration = 4.5, + showProgress, + hovering: forcedHovering, + pauseOnHover = true, + + // Function + onClick, + onMouseEnter, + onMouseLeave, + onClose, + } = props; + + const [percent, setPercent] = React.useState(0); + const noticePrefixCls = `${prefixCls}-notice`; + + // ========================= Close ========================== + const [mergedClosable, closableConfig, closeBtnAriaProps] = useClosable(closable); + const onInternalClose = useEvent(() => { + closableConfig.onClose?.(); + onClose?.(); + }); + + // ======================== Duration ======================== + const [hovering, setHovering] = React.useState(false); + + const [onResume, onPause] = useNoticeTimer(duration, onInternalClose, setPercent, !!showProgress); + + const validPercent = 100 - Math.min(Math.max(percent * 100, 0), 100); + const Progress = components?.progress || DefaultProgress; + + React.useEffect(() => { + if (!pauseOnHover) { + return; + } + + if (forcedHovering) { + onPause(); + } else if (!hovering) { + onResume(); + } + }, [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); + } + + function onInternalCloseClick(event: React.MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + onInternalClose(); + } + + // ======================== Position ======================== + const offsetRef = React.useRef(offset); + if (offset !== undefined) { + offsetRef.current = offset; + } + + const mergedOffset = offset ?? offsetRef.current; + const mergedNotificationIndex = notificationIndex ?? 0; + + // ======================== Content ========================= + const titleNode = + title !== undefined && title !== null ? ( +
{title}
+ ) : null; + + const descNode = + description !== undefined && description !== null ? ( +
{description}
+ ) : null; + + const hasTitle = titleNode !== null; + const hasDescription = descNode !== null; + let contentNode: React.ReactNode = null; + + if (hasTitle && hasDescription) { + contentNode = ( +
+ {titleNode} + {descNode} +
+ ); + } else { + contentNode = titleNode || descNode; + } + + if (icon !== undefined && icon !== null) { + contentNode = ( +
+
+ {icon} +
+ {contentNode} +
+ ); + } + + // ========================= Render ========================= + const mergedStyle: React.CSSProperties & { + '--notification-index'?: number; + '--notification-y'?: string; + } = { + '--notification-index': mergedNotificationIndex, + ...styles?.root, + ...style, + }; + + if (mergedOffset !== undefined) { + mergedStyle['--notification-y'] = `${mergedOffset}px`; + } + + return ( +
+ {contentNode} + + {mergedClosable && ( + + )} + + {actions &&
{actions}
} + + {showProgress && typeof duration === 'number' && duration > 0 && ( + + )} +
+ ); +}); + +if (process.env.NODE_ENV !== 'production') { + Notification.displayName = 'Notification'; +} + +export default Notification; 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/index.tsx b/src/NotificationList/index.tsx new file mode 100644 index 0000000..ba0b36e --- /dev/null +++ b/src/NotificationList/index.tsx @@ -0,0 +1,307 @@ +import { CSSMotionList } from '@rc-component/motion'; +import type { CSSMotionProps } from '@rc-component/motion'; +import { useComposeRef } from '@rc-component/util/lib/ref'; +import { clsx } from 'clsx'; +import * as React from 'react'; +import useListPosition from '../hooks/useListPosition'; +import useStack, { type StackConfig } from '../hooks/useStack'; +import Notification, { + type ComponentsType, + type NotificationClassNames as NoticeClassNames, + type NotificationProps, + type NotificationStyles as NoticeStyles, +} from '../Notification'; +import { NotificationContext, type NotificationContextProps } from '../NotificationProvider'; +import Content from './Content'; + +export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; +export type { StackConfig } from '../hooks/useStack'; + +export interface NotificationListConfig extends Omit { + key: React.Key; + placement?: Placement; + times?: number; +} + +export interface NotificationClassNames extends NoticeClassNames { + listContent?: string; +} + +export interface NotificationStyles extends NoticeStyles { + listContent?: React.CSSProperties; +} + +export interface NotificationListProps { + configList?: NotificationListConfig[]; + prefixCls?: string; + placement: Placement; + pauseOnHover?: boolean; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + components?: ComponentsType; + stack?: boolean | StackConfig; + motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); + className?: string; + style?: React.CSSProperties; + onNoticeClose?: (key: React.Key) => void; + onAllRemoved?: (placement: Placement) => void; +} + +const noticeSlotKeys = ['wrapper', 'root', 'icon', 'section', 'close', 'progress'] as const; + +function fillClassNames( + classNamesList: (NotificationClassNames | undefined)[], +): NotificationClassNames { + return noticeSlotKeys.reduce((mergedClassNames, key) => { + mergedClassNames[key] = clsx(...classNamesList.map((classNames) => classNames?.[key])); + + return mergedClassNames; + }, {}); +} + +function fillStyles(stylesList: (NotificationStyles | undefined)[]): NotificationStyles { + return noticeSlotKeys.reduce((mergedStyles, key) => { + mergedStyles[key] = Object.assign({}, ...stylesList.map((styles) => styles?.[key])); + + return mergedStyles; + }, {}); +} + +interface NotificationListItemProps { + config: NotificationListConfig; + components?: ComponentsType; + contextClassNames?: NotificationContextProps['classNames']; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + motionClassName?: string; + motionStyle?: React.CSSProperties; + nodeRef: React.Ref; + prefixCls: string; + offset?: number; + notificationIndex: number; + stackInThreshold: boolean; + listHovering: boolean; + stackEnabled: boolean; + pauseOnHover?: boolean; + setNodeSize: (key: string, node: HTMLDivElement | null) => void; + onNoticeClose?: (key: React.Key) => void; +} + +const NotificationListItem: React.FC = (props) => { + const { + config, + components, + contextClassNames, + classNames, + styles, + motionClassName, + motionStyle, + nodeRef, + listHovering, + stackEnabled, + pauseOnHover, + setNodeSize, + onNoticeClose, + ...restProps + } = props; + const { key, placement: itemPlacement, ...notificationConfig } = config; + const strKey = String(key); + + const setItemRef = React.useCallback( + (node: HTMLDivElement | null) => { + setNodeSize(strKey, node); + }, + [setNodeSize, strKey], + ); + const ref = useComposeRef(nodeRef, setItemRef); + + return ( + { + config.onClose?.(); + onNoticeClose?.(key); + }} + /> + ); +}; + +const NotificationList: React.FC = (props) => { + const { + configList = [], + prefixCls = 'rc-notification', + pauseOnHover, + classNames, + styles, + components, + stack: stackConfig, + motion, + placement, + className, + style, + onNoticeClose, + onAllRemoved, + } = props; + const { classNames: contextClassNames } = React.useContext(NotificationContext); + + // ========================== Data ========================== + const keys = React.useMemo( + () => + configList.map((config) => ({ + config, + key: String(config.key), + })), + [configList], + ); + const keyList = React.useMemo( + () => configList.map((config) => String(config.key)).reverse(), + [configList], + ); + + // ===================== Motion Config ====================== + const placementMotion = typeof motion === 'function' ? motion(placement) : motion; + + // ====================== Stack State ======================= + const [stackEnabled, { offset, threshold }] = useStack(stackConfig); + const [listHovering, setListHovering] = React.useState(false); + const expanded = stackEnabled && (listHovering || keys.length <= threshold); + + // ====================== Stack Layout ====================== + const stackPosition = React.useMemo(() => { + if (!stackEnabled || expanded) { + return undefined; + } + + return { + offset, + threshold, + }; + }, [expanded, offset, stackEnabled, threshold]); + + // ====================== List Measure ====================== + const [gap, setGap] = React.useState(0); + const contentRef = React.useRef(null); + const [notificationPosition, setNodeSize, totalHeight] = useListPosition( + configList, + stackPosition, + gap, + ); + const hasConfigList = !!configList.length; + + React.useEffect(() => { + const listNode = contentRef.current; + + if (!listNode) { + return; + } + + // CSS gap impacts stack offset and total list height calculation. + const { gap: cssGap, rowGap } = window.getComputedStyle(listNode); + const nextGap = parseFloat(rowGap || cssGap) || 0; + + setGap((prevGap) => (prevGap === nextGap ? prevGap : nextGap)); + }, [hasConfigList]); + + // ========================= Render ========================= + const listPrefixCls = `${prefixCls}-list`; + + return ( +
{ + setListHovering(true); + }} + onMouseLeave={() => { + setListHovering(false); + }} + style={style} + > + + { + if (placement) { + onAllRemoved?.(placement); + } + }} + > + {({ config, className: motionClassName, style: motionStyle, index = 0 }, nodeRef) => { + const { key } = config; + const strKey = String(key); + const notificationIndex = keyList.length - index - 1; + const stackInThreshold = stackEnabled && notificationIndex < threshold; + + return ( + + ); + }} + + +
+ ); +}; + +export default NotificationList; diff --git a/src/NotificationProvider.tsx b/src/NotificationProvider.tsx index 798b7e7..663f207 100644 --- a/src/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/Notifications.tsx b/src/Notifications.tsx index 04f6847..2cf0466 100644 --- a/src/Notifications.tsx +++ b/src/Notifications.tsx @@ -2,18 +2,36 @@ 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'; +import { useEvent } from '@rc-component/util'; +import NotificationList, { + type NotificationClassNames, + type NotificationListConfig, + type NotificationStyles, + type Placement, + type StackConfig, +} from './NotificationList'; +import type { ComponentsType } from './Notification'; export interface NotificationsProps { + // Style prefixCls?: string; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - container?: HTMLElement | ShadowRoot; - maxCount?: number; + classNames?: NotificationClassNames; + styles?: NotificationStyles; + components?: ComponentsType; 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 }, @@ -21,56 +39,55 @@ export interface NotificationsProps { } export interface NotificationsRef { - open: (config: OpenConfig) => void; + open: (config: NotificationListConfig) => void; close: (key: React.Key) => void; destroy: () => void; } -// ant-notification ant-notification-topRight +// ========================= Types ========================== +type Placements = Partial>; + const Notifications = React.forwardRef((props, ref) => { + // ========================= Props ========================== const { prefixCls = 'rc-notification', container, motion, maxCount, + pauseOnHover, + classNames, + styles, + components, 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 ========================= + + // ========================= State ========================== + const [configList, setConfigList] = React.useState([]); + const [placements, setPlacements] = React.useState({}); + const emptyRef = React.useRef(false); + + // ========================== Ref =========================== 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 }; + const innerConfig: NotificationListConfig = { ...config }; + if (index >= 0) { - innerConfig.times = ((list[index] as InnerOpenConfig)?.times || 0) + 1; + innerConfig.times = (list[index]?.times ?? 0) + 1; clone[index] = innerConfig; } else { innerConfig.times = 0; clone.push(innerConfig); } - if (maxCount > 0 && clone.length > maxCount) { + if (maxCount && maxCount > 0 && clone.length > maxCount) { clone = clone.slice(-maxCount); } @@ -78,64 +95,56 @@ const Notifications = React.forwardRef((pr }); }, close: (key) => { - onNoticeClose(key); + setConfigList((list) => list.filter((item) => item.key !== key)); }, destroy: () => { setConfigList([]); }, })); - // ====================== Placements ====================== - const [placements, setPlacements] = React.useState({}); - + // ======================== Effect ========================= React.useEffect(() => { const nextPlacements: Placements = {}; configList.forEach((config) => { - const { placement = 'topRight' } = config; - - if (placement) { - nextPlacements[placement] = nextPlacements[placement] || []; - nextPlacements[placement].push(config); - } + const placement = config.placement ?? 'topRight'; + 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] || []; + nextPlacements[placement as Placement] = nextPlacements[placement as Placement] || []; }); setPlacements(nextPlacements); }, [configList]); - // Clean up container if all notices fade out - const onAllNoticeRemoved = (placement: Placement) => { + // ======================== Callback ======================= + const onAllNoticeRemoved = useEvent((placement: Placement) => { setPlacements((originPlacements) => { const clone = { ...originPlacements, }; - const list = clone[placement] || []; - if (!list.length) { + if (!(clone[placement] || []).length) { delete clone[placement]; } return clone; }); - }; + }); - // Effect tell that placements is empty now - const emptyRef = React.useRef(false); + // ======================== Effect ========================= 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 ======================== + }, [placements, onAllRemoved]); + + // ======================== Render ========================= if (!container) { return null; } @@ -145,25 +154,31 @@ const Notifications = React.forwardRef((pr return createPortal( <> {placementList.map((placement) => { - const placementConfigList = placements[placement]; - const list = ( - { + setConfigList((oriList) => oriList.filter((item) => item.key !== key)); + }} + onAllRemoved={onAllNoticeRemoved} /> ); return renderNotifications - ? renderNotifications(list, { prefixCls, key: placement }) + ? React.cloneElement(renderNotifications(list, { prefixCls, key: placement }), { + key: placement, + }) : list; })} , diff --git a/src/Progress.tsx b/src/Progress.tsx new file mode 100644 index 0000000..abb5c30 --- /dev/null +++ b/src/Progress.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +export interface NotificationProgressProps { + className?: string; + style?: React.CSSProperties; + percent: number; +} + +const Progress: React.FC = ({ className, style, percent }) => ( + +); + +export default Progress; diff --git a/src/hooks/useClosable.ts b/src/hooks/useClosable.ts new file mode 100644 index 0000000..66c2d99 --- /dev/null +++ b/src/hooks/useClosable.ts @@ -0,0 +1,51 @@ +import pickAttrs from '@rc-component/util/lib/pickAttrs'; +import * as React from 'react'; + +export type ClosableConfig = { + closeIcon?: React.ReactNode; + disabled?: boolean; + onClose?: VoidFunction; +} & React.AriaAttributes; + +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] { + // Convert boolean shorthand into the object shape used by render logic. + const closableObj = React.useMemo(() => { + if (closable === false) { + return { + closeIcon: null, + disabled: true, + }; + } + + if (typeof closable === 'object' && closable !== null) { + return closable; + } + + return {}; + }, [closable]); + + // Fill defaults so callers can read closeIcon and disabled without extra guards. + const closableConfig = React.useMemo( + () => ({ + ...closableObj, + closeIcon: 'closeIcon' in closableObj ? closableObj.closeIcon : '×', + disabled: closableObj.disabled ?? false, + }), + [closableObj], + ); + + // Forward aria-* props from the closable config to the close button. + const closableAriaProps = React.useMemo(() => pickAttrs(closableConfig, true), [closableConfig]); + + return [!!closable, closableConfig, closableAriaProps]; +} diff --git a/src/hooks/useListPosition/index.ts b/src/hooks/useListPosition/index.ts new file mode 100644 index 0000000..3d0b2c8 --- /dev/null +++ b/src/hooks/useListPosition/index.ts @@ -0,0 +1,47 @@ +import * as React from 'react'; +import type { StackConfig } from '../useStack'; +import useSizes from './useSizes'; + +/** + * Calculates each notification's position and the full list height. + */ +export default function useListPosition( + configList: { key: React.Key }[], + stack?: StackConfig, + gap = 0, +) { + const [sizeMap, setNodeSize] = useSizes(); + + const [notificationPosition, totalHeight] = React.useMemo(() => { + let offsetY = 0; + let nextTotalHeight = 0; + const stackThreshold = stack?.threshold ?? 0; + const nextNotificationPosition = new Map(); + + configList + .slice() + .reverse() + .forEach((config, index) => { + // Walk from newest to oldest so each notice can be positioned after the ones below it. + const key = String(config.key); + const height = sizeMap[key]?.height ?? 0; + const y = stack && index > 0 ? offsetY + (stack.offset ?? 0) - height : offsetY; + + nextNotificationPosition.set(key, y); + + if (!stack || index < stackThreshold) { + nextTotalHeight = Math.max(nextTotalHeight, y + height); + } + + if (stack) { + offsetY = y + height; + } else { + offsetY += height + gap; + } + }); + + return [nextNotificationPosition, nextTotalHeight] as const; + }, [configList, gap, sizeMap, stack]); + + return [notificationPosition, setNodeSize, totalHeight] as const; +} diff --git a/src/hooks/useListPosition/useSizes.ts b/src/hooks/useListPosition/useSizes.ts new file mode 100644 index 0000000..ba82e21 --- /dev/null +++ b/src/hooks/useListPosition/useSizes.ts @@ -0,0 +1,49 @@ +import * as React from 'react'; + +export type NodeSize = { + width: number; + height: number; +}; + +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({}); + + const setNodeSize = React.useCallback((key: string, node: HTMLDivElement | null) => { + if (!node) { + setSizeMap((prevSizeMap) => { + if (!(key in prevSizeMap)) { + return prevSizeMap; + } + + const nextSizeMap = { ...prevSizeMap }; + delete nextSizeMap[key]; + return nextSizeMap; + }); + 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; +} diff --git a/src/hooks/useNoticeTimer.ts b/src/hooks/useNoticeTimer.ts new file mode 100644 index 0000000..6eeedb9 --- /dev/null +++ b/src/hooks/useNoticeTimer.ts @@ -0,0 +1,98 @@ +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, + onUpdate: (ptg: number) => void, + trackProgress = true, +) { + const mergedDuration = typeof duration === 'number' ? duration : 0; + const durationMs = Math.max(mergedDuration, 0) * 1000; + const onEventClose = useEvent(onClose); + const onEventUpdate = useEvent(onUpdate); + + const [walking, setWalking] = React.useState(durationMs > 0); + const startTimestampRef = React.useRef(null); + const passTimeRef = React.useRef(0); + + function syncPassTime() { + const now = Date.now(); + const passedTime = now - (startTimestampRef.current || now); + startTimestampRef.current = now; + passTimeRef.current += passedTime; + } + + const onPause = React.useCallback(() => { + syncPassTime(); + setWalking(false); + }, []); + + const onResume = React.useCallback(() => { + if (durationMs > 0) { + setWalking(true); + } else { + onEventUpdate(0); + } + }, [durationMs, onEventUpdate]); + + React.useEffect(() => { + if (durationMs <= 0) { + startTimestampRef.current = null; + onEventUpdate(0); + return; + } + + syncPassTime(); + onEventUpdate(Math.min(passTimeRef.current / durationMs, 1)); + + if (!walking) { + startTimestampRef.current = null; + return; + } + + 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) { + rafId = raf(step); + } + } + + startTimestampRef.current = Date.now(); + rafId = raf(step); + + return () => { + window.clearTimeout(timeout); + raf.cancel(rafId); + }; + }, [durationMs, walking]); + + return [onResume, onPause] as const; +} diff --git a/src/hooks/useNotification.tsx b/src/hooks/useNotification.tsx index eb51b8b..9e1849a 100644 --- a/src/hooks/useNotification.tsx +++ b/src/hooks/useNotification.tsx @@ -1,36 +1,27 @@ -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'; +import * as React from 'react'; +import Notifications, { type NotificationsProps, type NotificationsRef } from '../Notifications'; +import type { NotificationListConfig } from '../NotificationList'; +import type { Placement } from '../NotificationList'; const defaultGetContainer = () => document.body; -type OptionalConfig = Partial; +// ========================= Types ========================== +type OptionalConfig = Partial; +type SharedConfig = Pick< + NotificationListConfig, + 'placement' | 'closable' | 'duration' | 'showProgress' +>; -export interface NotificationConfig { - prefixCls?: string; - /** Customize container. It will repeat call which means you should return same container element. */ +export interface NotificationConfig extends Omit { + // UI + placement?: Placement; getContainer?: () => HTMLElement | ShadowRoot; - motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); - closable?: - | boolean - | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); - maxCount?: number; + // Behavior + closable?: NotificationListConfig['closable']; 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']; + showProgress?: NotificationListConfig['showProgress']; } export interface NotificationAPI { @@ -41,7 +32,7 @@ export interface NotificationAPI { interface OpenTask { type: 'open'; - config: OpenConfig; + config: NotificationListConfig; } interface CloseTask { @@ -55,18 +46,19 @@ interface DestroyTask { type Task = OpenTask | CloseTask | DestroyTask; +// ======================== Helper ========================== let uniqueKey = 0; function mergeConfig(...objList: Partial[]): T { - const clone: T = {} as T; + const clone = {} as T; objList.forEach((obj) => { if (obj) { Object.keys(obj).forEach((key) => { - const val = obj[key]; + const value = obj[key as keyof T]; - if (val !== undefined) { - clone[key] = val; + if (value !== undefined) { + clone[key as keyof T] = value; } }); } @@ -75,24 +67,45 @@ 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] { + // ========================= Config ========================= const { getContainer = defaultGetContainer, motion, prefixCls, + placement, + closable, + duration, + showProgress, + pauseOnHover, + classNames, + styles, + components, maxCount, className, style, onAllRemoved, stack, renderNotifications, - ...shareConfig } = rootConfig; - + const shareConfig: SharedConfig = { + placement, + closable, + duration, + showProgress, + }; + + // ========================= Holder ========================= const [container, setContainer] = React.useState(); - const notificationsRef = React.useRef(); + 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); + const mergedConfig = mergeConfig(shareConfig, config); + if (mergedConfig.key === null || mergedConfig.key === undefined) { mergedConfig.key = `rc-notification-${uniqueKey}`; uniqueKey += 1; @@ -120,10 +137,9 @@ export default function useNotification( setTaskQueue((queue) => [...queue, { type: 'open', config: mergedConfig }]); }); - // ========================= Refs ========================= const api = React.useMemo( () => ({ - open: open, + open, close: (key) => { setTaskQueue((queue) => [...queue, { type: 'close', key }]); }, @@ -134,56 +150,38 @@ export default function useNotification( [], ); - // ======================= Container ====================== + // ======================== Effect ========================= // React 18 should all in effect that we will check container in each render // Which means getContainer should be stable. React.useEffect(() => { setContainer(getContainer()); }); - // ======================== Effect ======================== React.useEffect(() => { // Flush task when node ready if (notificationsRef.current && taskQueue.length) { taskQueue.forEach((task) => { switch (task.type) { case 'open': - notificationsRef.current.open(task.config); + notificationsRef.current?.open(task.config); break; - case 'close': - notificationsRef.current.close(task.key); + notificationsRef.current?.close(task.key); break; - case 'destroy': - notificationsRef.current.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 `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 - setTaskQueue((oriQueue) => { - if (oriTaskQueue !== oriQueue || !tgtTaskQueue) { - oriTaskQueue = oriQueue; - tgtTaskQueue = oriQueue.filter((task) => !taskQueue.includes(task)); - } + setTaskQueue((originQueue) => { + const targetTaskQueue = originQueue.filter((task) => !taskQueue.includes(task)); - return tgtTaskQueue; + return targetTaskQueue.length === originQueue.length ? originQueue : targetTaskQueue; }); } }, [taskQueue]); - // ======================== Return ======================== + // ======================== Return ========================= return [api, contextHolder]; } diff --git a/src/hooks/useStack.ts b/src/hooks/useStack.ts index 0ecf708..4e3b6b9 100644 --- a/src/hooks/useStack.ts +++ b/src/hooks/useStack.ts @@ -1,24 +1,29 @@ -import type { StackConfig } from '../interface'; +export interface StackConfig { + threshold?: number; + offset?: number; +} const DEFAULT_OFFSET = 8; const DEFAULT_THRESHOLD = 3; -const DEFAULT_GAP = 16; -type StackParams = Exclude; +type StackParams = Required; -type UseStack = (config?: StackConfig) => [boolean, StackParams]; +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, 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]; }; diff --git a/src/index.ts b/src/index.ts index f5928e4..3df7228 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,16 @@ import useNotification from './hooks/useNotification'; -import Notice from './Notice'; import type { NotificationAPI, NotificationConfig } from './hooks/useNotification'; 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 }; -export type { NotificationAPI, NotificationConfig }; +export { useNotification, NotificationProvider, Progress, Notification }; +export type { + NotificationAPI, + NotificationConfig, + ComponentsType, + NotificationProps, + NotificationProgressProps, +}; diff --git a/src/interface.ts b/src/interface.ts deleted file mode 100644 index c50ddc1..0000000 --- a/src/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; - }; diff --git a/tests/hooks.test.tsx b/tests/hooks.test.tsx index 42a372e..8313514 100644 --- a/tests/hooks.test.tsx +++ b/tests/hooks.test.tsx @@ -42,7 +42,7 @@ describe('Notification.Hooks', () => { onClick={() => { api.open({ duration: 0.1, - content: ( + description: ( {({ name }) =>
{name}
}
@@ -79,14 +79,14 @@ describe('Notification.Hooks', () => { api.open({ key: 'little', duration: 1000, - content:
light
, + description:
light
, }); setTimeout(() => { api.open({ key: 'little', duration: 1000, - content:
bamboo
, + description:
bamboo
, }); }, 500); }} @@ -116,7 +116,7 @@ describe('Notification.Hooks', () => { act(() => { instance.open({ - content:
, + description:
, }); }); @@ -130,7 +130,7 @@ describe('Notification.Hooks', () => { // Can be override act(() => { instance.open({ - content:
, + description:
, duration: 1, }); }); @@ -143,7 +143,7 @@ describe('Notification.Hooks', () => { // Can be undefined act(() => { instance.open({ - content:
, + description:
, duration: undefined, }); }); @@ -172,7 +172,7 @@ describe('Notification.Hooks', () => { act(() => { instance.open({ - content:
, + description:
, style: { color: 'red' }, className: 'custom-notice', }); diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 6b47858..1a48e64 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import type { NotificationAPI, NotificationConfig } from '../src'; -import { useNotification } from '../src'; +import type { NotificationAPI, NotificationConfig, NotificationProgressProps } from '../src'; +import { Notification, useNotification } from '../src'; require('../assets/index.less'); @@ -37,7 +37,7 @@ describe('Notification.Basic', () => { act(() => { instance.open({ - content:

1

, + description:

1

, duration: 0.1, }); }); @@ -56,7 +56,7 @@ describe('Notification.Basic', () => { act(() => { instance.open({ - content:

1

, + description:

1

, closable: { closeIcon: test-close-icon, }, @@ -73,13 +73,13 @@ describe('Notification.Basic', () => { act(() => { instance.open({ - content:

1

, + description:

1

, duration: 0.1, }); }); act(() => { instance.open({ - content:

2

, + description:

2

, duration: 0.1, }); }); @@ -97,7 +97,7 @@ describe('Notification.Basic', () => { act(() => { instance.open({ - content: ( + description: (

222222

@@ -126,7 +126,7 @@ describe('Notification.Basic', () => { act(() => { instance.open({ - content: ( + description: (

222222

@@ -154,7 +154,7 @@ describe('Notification.Basic', () => { act(() => { instance.open({ - content: ( + description: (