├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.ts ├── .github ├── dependabot.yml └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── assets └── index.less ├── bunfig.toml ├── docs ├── demo │ ├── context.md │ ├── hooks.md │ ├── maxCount.md │ ├── showProgress.md │ └── stack.md ├── examples │ ├── context.tsx │ ├── hooks.tsx │ ├── maxCount.tsx │ ├── motion.ts │ ├── showProgress.tsx │ └── stack.tsx └── index.md ├── jest.config.js ├── now.json ├── package.json ├── src ├── Notice.tsx ├── NoticeList.tsx ├── NotificationProvider.tsx ├── Notifications.tsx ├── hooks │ ├── useNotification.tsx │ └── useStack.ts ├── index.ts └── interface.ts ├── tests ├── hooks.test.tsx ├── index.test.tsx └── stack.test.tsx ├── tsconfig.json ├── type.d.ts ├── vitest-setup.ts └── vitest.config.ts /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | alias: { 6 | '@rc-component/notification$': path.resolve('src'), 7 | '@rc-component/notification/es': path.resolve('src'), 8 | }, 9 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 10 | themeConfig: { 11 | name: 'Notification', 12 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@umijs/fabric/dist/eslint')], 3 | rules: { 4 | 'react/sort-comp': 0, 5 | 'react/require-default-props': 0, 6 | 'jsx-a11y/no-noninteractive-tabindex': 0, 7 | }, 8 | overrides: [ 9 | { 10 | // 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 11 | files: ['tests/*.test.tsx'], 12 | parserOptions: { project: './tsconfig.test.json' }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/react-dom" 11 | versions: 12 | - 17.0.0 13 | - 17.0.1 14 | - 17.0.2 15 | - dependency-name: "@types/react" 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - 17.0.3 21 | - dependency-name: np 22 | versions: 23 | - 7.2.0 24 | - 7.3.0 25 | - 7.4.0 26 | - dependency-name: react 27 | versions: 28 | - 17.0.1 29 | - dependency-name: typescript 30 | versions: 31 | - 4.1.3 32 | - 4.1.4 33 | - 4.1.5 34 | - 4.2.2 35 | - dependency-name: less 36 | versions: 37 | - 4.1.0 38 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "4 14 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v3 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | *.log.* 5 | .idea/ 6 | .ipr 7 | .iws 8 | *~ 9 | ~* 10 | *.diff 11 | *.patch 12 | *.bak 13 | .DS_Store 14 | Thumbs.db 15 | .project 16 | .*proj 17 | .svn/ 18 | *.swp 19 | *.swo 20 | *.pyc 21 | *.pyo 22 | .build 23 | node_modules 24 | .cache 25 | dist 26 | assets/**/*.css 27 | build 28 | lib 29 | es 30 | coverage 31 | yarn.lock 32 | package-lock.json 33 | 34 | # umi 35 | .umi 36 | .umi-production 37 | .umi-test 38 | .env.local 39 | 40 | # dumi 41 | .dumi/tmp 42 | .dumi/tmp-production 43 | 44 | bun.lockb -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | - https://github.com/react-component/notification/releases 4 | 5 | ## 4.3.0 6 | 7 | - Upgrade `rc-animate` to `3.x`. 8 | 9 | ## 3.3.0 10 | 11 | - Add `onClick` property. 12 | 13 | ## 3.2.0 14 | 15 | - Add `closeIcon`. [#45](https://github.com/react-component/notification/pull/45) [@HeskeyBaozi](https://github.com/HeskeyBaozi) 16 | 17 | ## 2.0.0 18 | 19 | - [Beack Change] Remove wrapper span element when just single notification. [#17](https://github.com/react-component/notification/pull/17) 20 | 21 | ## 1.4.0 22 | 23 | - Added `getContainer` property. 24 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-present yiminghe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @rc-component/notification 2 | 3 | React Notification UI Component 4 | 5 | [![NPM version][npm-image]][npm-url] [![dumi](https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square)](https://github.com/umijs/dumi) [![build status][github-actions-image]][github-actions-url] [![Test coverage][coveralls-image]][coveralls-url] [![npm download][download-image]][download-url] [![bundle size][bundlephobia-image]][bundlephobia-url] 6 | 7 | [npm-image]: http://img.shields.io/npm/v/@rc-component/notification.svg?style=flat-square 8 | [npm-url]: http://npmjs.org/package/@rc-component/notification 9 | [github-actions-image]: https://github.com/react-component/notification/workflows/CI/badge.svg 10 | [github-actions-url]: https://github.com/react-component/notification/actions 11 | [coveralls-image]: https://img.shields.io/coveralls/react-component/notification.svg?style=flat-square 12 | [coveralls-url]: https://coveralls.io/r/react-component/notification?branch=master 13 | [download-image]: https://img.shields.io/npm/dm/@rc-component/notification.svg?style=flat-square 14 | [download-url]: https://npmjs.org/package/@rc-component/notification 15 | [bundlephobia-url]: https://bundlephobia.com/result?p=@rc-component/notification 16 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/@rc-component/notification 17 | 18 | ## Install 19 | 20 | [![@rc-component/notification](https://nodei.co/npm/@rc-component/notification.png)](https://npmjs.org/package/@rc-component/notification) 21 | 22 | ## Usage 23 | 24 | ```js 25 | import Notification from '@rc-component/notification'; 26 | 27 | Notification.newInstance({}, (notification) => { 28 | notification.notice({ 29 | content: 'content', 30 | }); 31 | }); 32 | ``` 33 | 34 | ## Compatibility 35 | 36 | | Browser | Supported Version | 37 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | 38 | | [![Firefox](https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png)
Firefox](http://godban.github.io/browsers-support-badges/) | last 2 versions | 39 | | [![Chrome](https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png)
Chrome](http://godban.github.io/browsers-support-badges/) | last 2 versions | 40 | | [![Safari](https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png)
Safari](http://godban.github.io/browsers-support-badges/) | last 2 versions | 41 | | [![Electron](https://raw.githubusercontent.com/alrra/browser-logos/master/src/electron/electron_48x48.png)
Electron](http://godban.github.io/browsers-support-badges/) | last 2 versions | 42 | 43 | ## Example 44 | 45 | http://localhost:8001 46 | 47 | online example: https://notification-react-component.vercel.app 48 | 49 | ## API 50 | 51 | ### Notification.newInstance(props, (notification) => void) => void 52 | 53 | props details: 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 |
nametypedefaultdescription
prefixClsStringprefix class name for notification container
styleObject{'top': 65, left: '50%'}additional style for notification container.
getContainergetContainer(): HTMLElementfunction returning html node which will act as notification container
maxCountnumbermax notices show, drop first notice if exceed limit
91 | 92 | ### notification.notice(props) 93 | 94 | props details: 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 |
nametypedefaultdescription
contentReact.Elementcontent of notice
keyStringid of this notice
closableBooleanwhether show close button
onCloseFunctioncalled when notice close
durationnumber1.5after duration of time, this notice will disappear.(seconds)
showProgressbooleanfalseshow with progress bar for auto-closing notification
pauseOnHoverbooleantruekeep the timer running or not on hover
styleObject { right: '50%' } additional style for single notice node.
closeIconReactNodespecific the close icon.
propsObjectAn object that can contain data-*, aria-*, or role props, to be put on the notification div. This currently only allows data-testid instead of data-* in TypeScript. See https://github.com/microsoft/TypeScript/issues/28960.
168 | 169 | ### notification.removeNotice(key:string) 170 | 171 | remove single notice with specified key 172 | 173 | ### notification.destroy() 174 | 175 | destroy current notification 176 | 177 | ## Test Case 178 | 179 | ``` 180 | npm test 181 | npm run chrome-test 182 | ``` 183 | 184 | ## Coverage 185 | 186 | ``` 187 | npm run coverage 188 | ``` 189 | 190 | open coverage/ dir 191 | 192 | ## License 193 | 194 | @rc-component/notification is released under the MIT license. 195 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @notificationPrefixCls: rc-notification; 2 | 3 | .@{notificationPrefixCls} { 4 | // ====================== Notification ====================== 5 | position: fixed; 6 | z-index: 1000; 7 | display: flex; 8 | max-height: 100vh; 9 | padding: 10px; 10 | align-items: flex-end; 11 | width: 340px; 12 | overflow-x: hidden; 13 | overflow-y: auto; 14 | height: 100vh; 15 | box-sizing: border-box; 16 | pointer-events: none; 17 | flex-direction: column; 18 | 19 | // Position 20 | &-top, 21 | &-topLeft, 22 | &-topRight { 23 | top: 0; 24 | } 25 | 26 | &-bottom, 27 | &-bottomRight, 28 | &-bottomLeft { 29 | bottom: 0; 30 | } 31 | 32 | &-bottomRight, 33 | &-topRight { 34 | right: 0; 35 | } 36 | 37 | // ========================= Notice ========================= 38 | &-notice { 39 | position: relative; 40 | display: block; 41 | box-sizing: border-box; 42 | line-height: 1.5; 43 | width: 100%; 44 | 45 | &-wrapper { 46 | pointer-events: auto; 47 | position: relative; 48 | display: block; 49 | box-sizing: border-box; 50 | border-radius: 3px 3px; 51 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); 52 | margin: 0 0 16px; 53 | border: 1px solid #999; 54 | border: 0px solid rgba(0, 0, 0, 0); 55 | background: #fff; 56 | width: 300px; 57 | } 58 | 59 | // Content 60 | &-content { 61 | padding: 7px 20px 7px 10px; 62 | } 63 | 64 | &-closable &-content { 65 | padding-right: 20px; 66 | } 67 | 68 | &-close { 69 | position: absolute; 70 | top: 3px; 71 | right: 5px; 72 | color: #000; 73 | font-weight: 700; 74 | font-size: 16px; 75 | line-height: 1; 76 | text-decoration: none; 77 | text-shadow: 0 1px 0 #fff; 78 | outline: none; 79 | cursor: pointer; 80 | opacity: 0.2; 81 | filter: alpha(opacity=20); 82 | border: 0; 83 | background-color: #fff; 84 | 85 | &-x:after { 86 | content: '×'; 87 | } 88 | 89 | &:hover { 90 | text-decoration: none; 91 | opacity: 1; 92 | filter: alpha(opacity=100); 93 | } 94 | } 95 | 96 | // Progress 97 | &-progress { 98 | position: absolute; 99 | left: 3px; 100 | right: 3px; 101 | border-radius: 1px; 102 | overflow: hidden; 103 | appearance: none; 104 | -webkit-appearance: none; 105 | display: block; 106 | inline-size: 100%; 107 | block-size: 2px; 108 | border: 0; 109 | 110 | &, 111 | &::-webkit-progress-bar { 112 | background-color: rgba(0, 0, 0, 0.04); 113 | } 114 | 115 | &::-moz-progress-bar { 116 | background-color: #31afff; 117 | } 118 | 119 | &::-webkit-progress-value { 120 | background-color: #31afff; 121 | } 122 | } 123 | } 124 | 125 | &-fade { 126 | overflow: hidden; 127 | transition: all 0.3s; 128 | } 129 | 130 | &-fade-appear-prepare { 131 | pointer-events: none; 132 | opacity: 0 !important; 133 | } 134 | 135 | &-fade-appear-start { 136 | transform: translateX(100%); 137 | opacity: 0; 138 | } 139 | 140 | &-fade-appear-active { 141 | transform: translateX(0); 142 | opacity: 1; 143 | } 144 | 145 | // .fade-effect() { 146 | // animation-duration: 0.3s; 147 | // animation-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2); 148 | // animation-fill-mode: both; 149 | // } 150 | 151 | // &-fade-appear, 152 | // &-fade-enter { 153 | // opacity: 0; 154 | // animation-play-state: paused; 155 | // .fade-effect(); 156 | // } 157 | 158 | // &-fade-leave { 159 | // .fade-effect(); 160 | // animation-play-state: paused; 161 | // } 162 | 163 | // &-fade-appear&-fade-appear-active, 164 | // &-fade-enter&-fade-enter-active { 165 | // animation-name: rcNotificationFadeIn; 166 | // animation-play-state: running; 167 | // } 168 | 169 | // &-fade-leave&-fade-leave-active { 170 | // animation-name: rcDialogFadeOut; 171 | // animation-play-state: running; 172 | // } 173 | 174 | // @keyframes rcNotificationFadeIn { 175 | // 0% { 176 | // opacity: 0; 177 | // } 178 | // 100% { 179 | // opacity: 1; 180 | // } 181 | // } 182 | 183 | // @keyframes rcDialogFadeOut { 184 | // 0% { 185 | // opacity: 1; 186 | // } 187 | // 100% { 188 | // opacity: 0; 189 | // } 190 | // } 191 | 192 | // ========================= Stack ========================= 193 | &-stack { 194 | & > .@{notificationPrefixCls}-notice { 195 | &-wrapper { 196 | transition: all 0.3s; 197 | position: absolute; 198 | top: 12px; 199 | opacity: 1; 200 | 201 | &:not(:nth-last-child(-n + 3)) { 202 | opacity: 0; 203 | right: 34px; 204 | width: 252px; 205 | overflow: hidden; 206 | color: transparent; 207 | pointer-events: none; 208 | } 209 | 210 | &:nth-last-child(1) { 211 | right: 10px; 212 | } 213 | 214 | &:nth-last-child(2) { 215 | right: 18px; 216 | width: 284px; 217 | color: transparent; 218 | overflow: hidden; 219 | } 220 | 221 | &:nth-last-child(3) { 222 | right: 26px; 223 | width: 268px; 224 | color: transparent; 225 | overflow: hidden; 226 | } 227 | } 228 | } 229 | 230 | &&-expanded { 231 | & > .@{notificationPrefixCls}-notice { 232 | &-wrapper { 233 | &:not(:nth-last-child(-n + 1)) { 234 | opacity: 1; 235 | width: 300px; 236 | right: 10px; 237 | overflow: unset; 238 | color: inherit; 239 | pointer-events: auto; 240 | } 241 | 242 | &::after { 243 | content: ""; 244 | position: absolute; 245 | left: 0; 246 | right: 0; 247 | top: -16px; 248 | width: 100%; 249 | height: calc(100% + 32px); 250 | background: transparent; 251 | pointer-events: auto; 252 | color: rgb(0,0,0); 253 | } 254 | } 255 | } 256 | } 257 | 258 | &.@{notificationPrefixCls}-bottomRight { 259 | & > .@{notificationPrefixCls}-notice-wrapper { 260 | top: unset; 261 | bottom: 12px; 262 | } 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /docs/demo/context.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: context 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/hooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: hooks 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/maxCount.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: maxCount 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/showProgress.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: showProgress 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/stack.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: stack 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/examples/context.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { useNotification } from '../../src'; 5 | import motion from './motion'; 6 | 7 | const Context = React.createContext({ name: 'light' }); 8 | 9 | const NOTICE = { 10 | content: simple show, 11 | onClose() { 12 | console.log('simple close'); 13 | }, 14 | // duration: null, 15 | }; 16 | 17 | const Demo = () => { 18 | const [{ open }, holder] = useNotification({ motion }); 19 | 20 | return ( 21 | 22 | 36 | {holder} 37 | 38 | ); 39 | }; 40 | 41 | export default Demo; 42 | -------------------------------------------------------------------------------- /docs/examples/hooks.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { useNotification } from '../../src'; 5 | import motion from './motion'; 6 | 7 | const App = () => { 8 | const [notice, contextHolder] = useNotification({ motion, closable: true }); 9 | 10 | return ( 11 | <> 12 |
13 |
14 | {/* Default */} 15 | 24 | 25 | {/* Not Close */} 26 | 39 | 40 | {/* Not Close */} 41 | 54 |
55 | 56 |
57 | {/* No Closable */} 58 | 73 | 74 | {/* Force Close */} 75 | 82 |
83 |
84 | 85 |
86 | {/* Destroy All */} 87 | 94 |
95 | 96 | {contextHolder} 97 | 98 | ); 99 | }; 100 | 101 | export default () => ( 102 | 103 | 104 | 105 | ); 106 | -------------------------------------------------------------------------------- /docs/examples/maxCount.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { useNotification } from '../../src'; 5 | import motion from './motion'; 6 | 7 | export default () => { 8 | const [notice, contextHolder] = useNotification({ motion, maxCount: 3 }); 9 | 10 | return ( 11 | <> 12 | 21 | {contextHolder} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /docs/examples/motion.ts: -------------------------------------------------------------------------------- 1 | import type { CSSMotionProps } from '@rc-component/motion'; 2 | 3 | const motion: CSSMotionProps = { 4 | motionName: 'rc-notification-fade', 5 | motionAppear: true, 6 | motionEnter: true, 7 | motionLeave: true, 8 | onLeaveStart: (ele) => { 9 | const { offsetHeight } = ele; 10 | return { height: offsetHeight }; 11 | }, 12 | onLeaveActive: () => ({ height: 0, opacity: 0, margin: 0 }), 13 | }; 14 | 15 | export default motion; 16 | -------------------------------------------------------------------------------- /docs/examples/showProgress.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { useNotification } from '../../src'; 5 | import motion from './motion'; 6 | 7 | export default () => { 8 | const [notice, contextHolder] = useNotification({ motion, showProgress: true }); 9 | 10 | return ( 11 | <> 12 | 21 | 31 | {contextHolder} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /docs/examples/stack.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { useNotification } from '../../src'; 5 | import motion from './motion'; 6 | 7 | const Context = React.createContext({ name: 'light' }); 8 | 9 | const getConfig = () => ({ 10 | content: `${Array(Math.round(Math.random() * 5) + 1) 11 | .fill(1) 12 | .map(() => new Date().toISOString()) 13 | .join('\n')}`, 14 | duration: null, 15 | }); 16 | 17 | const Demo = () => { 18 | const [{ open }, holder] = useNotification({ motion, stack: true }); 19 | 20 | return ( 21 | 22 | 30 | 38 | {holder} 39 | 40 | ); 41 | }; 42 | 43 | export default Demo; 44 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-notification 4 | description: notification ui component for react 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/tests/setup.js'], 3 | snapshotSerializers: [require.resolve("enzyme-to-json/serializer")] 4 | }; 5 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-notification", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": "dist" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/notification", 3 | "version": "1.0.2", 4 | "description": "notification ui component for react", 5 | "engines": { 6 | "node": ">=8.x" 7 | }, 8 | "keywords": [ 9 | "react", 10 | "react-component", 11 | "react-notification", 12 | "notification" 13 | ], 14 | "homepage": "http://github.com/react-component/notification", 15 | "maintainers": [ 16 | "yiminghe@gmail.com", 17 | "skyking_H@hotmail.com", 18 | "hust2012jiangkai@gmail.com" 19 | ], 20 | "files": [ 21 | "assets/*.css", 22 | "assets/*.less", 23 | "es", 24 | "lib" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "git@github.com:react-component/notification.git" 29 | }, 30 | "bugs": { 31 | "url": "http://github.com/react-component/notification/issues" 32 | }, 33 | "license": "MIT", 34 | "main": "lib/index", 35 | "module": "es/index", 36 | "typings": "es/index.d.ts", 37 | "scripts": { 38 | "start": "dumi dev", 39 | "build": "dumi build", 40 | "docs:deploy": "gh-pages -d .doc", 41 | "compile": "father build && lessc assets/index.less assets/index.css", 42 | "prepublishOnly": "npm run compile && rc-np", 43 | "lint": "eslint src/ docs/examples/ --ext .tsx,.ts,.jsx,.js", 44 | "test": "vitest --watch=false", 45 | "test:watch": "vitest", 46 | "coverage": "vitest run --coverage", 47 | "now-build": "npm run build", 48 | "prepare": "husky install" 49 | }, 50 | "peerDependencies": { 51 | "react": ">=16.9.0", 52 | "react-dom": ">=16.9.0" 53 | }, 54 | "dependencies": { 55 | "@rc-component/motion": "^1.1.4", 56 | "@rc-component/util": "^1.2.1", 57 | "classnames": "2.x" 58 | }, 59 | "devDependencies": { 60 | "@typescript-eslint/eslint-plugin": "^5.59.7", 61 | "@typescript-eslint/parser": "^5.59.7", 62 | "@rc-component/father-plugin": "^2.0.4", 63 | "@rc-component/np": "^1.0.3", 64 | "@testing-library/jest-dom": "^6.0.0", 65 | "@testing-library/react": "^15.0.7", 66 | "@types/classnames": "^2.2.10", 67 | "@types/react": "^18.0.0", 68 | "@types/react-dom": "^18.0.0", 69 | "@types/testing-library__jest-dom": "^6.0.0", 70 | "@umijs/fabric": "^2.0.0", 71 | "@vitest/coverage-v8": "^0.34.2", 72 | "cross-env": "^7.0.0", 73 | "dumi": "^2.1.0", 74 | "eslint": "^7.8.1", 75 | "father": "^4.0.0", 76 | "gh-pages": "^3.1.0", 77 | "husky": "^8.0.3", 78 | "jsdom": "^24.0.0", 79 | "less": "^4.2.0", 80 | "lint-staged": "^14.0.1", 81 | "prettier": "^3.0.2", 82 | "react": "^18.0.0", 83 | "react-dom": "^18.0.0", 84 | "typescript": "^5.4.5", 85 | "vitest": "^0.34.2" 86 | }, 87 | "lint-staged": { 88 | "**/*.{js,jsx,tsx,ts,md,json}": [ 89 | "prettier --write", 90 | "git add" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Notice.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import KeyCode from '@rc-component/util/lib/KeyCode'; 3 | import * as React from 'react'; 4 | import type { NoticeConfig } from './interface'; 5 | import pickAttrs from '@rc-component/util/lib/pickAttrs'; 6 | 7 | export interface NoticeProps extends Omit { 8 | prefixCls: string; 9 | className?: string; 10 | style?: React.CSSProperties; 11 | eventKey: React.Key; 12 | 13 | onClick?: React.MouseEventHandler; 14 | onNoticeClose?: (key: React.Key) => void; 15 | hovering?: boolean; 16 | } 17 | 18 | const Notify = React.forwardRef((props, ref) => { 19 | const { 20 | prefixCls, 21 | style, 22 | className, 23 | duration = 4.5, 24 | showProgress, 25 | pauseOnHover = true, 26 | 27 | eventKey, 28 | content, 29 | closable, 30 | props: divProps, 31 | 32 | onClick, 33 | onNoticeClose, 34 | times, 35 | hovering: forcedHovering, 36 | } = props; 37 | const [hovering, setHovering] = React.useState(false); 38 | const [percent, setPercent] = React.useState(0); 39 | const [spentTime, setSpentTime] = React.useState(0); 40 | const mergedHovering = forcedHovering || hovering; 41 | const mergedShowProgress = duration > 0 && showProgress; 42 | 43 | // ======================== Close ========================= 44 | const onInternalClose = () => { 45 | onNoticeClose(eventKey); 46 | }; 47 | 48 | const onCloseKeyDown: React.KeyboardEventHandler = (e) => { 49 | if (e.key === 'Enter' || e.code === 'Enter' || e.keyCode === KeyCode.ENTER) { 50 | onInternalClose(); 51 | } 52 | }; 53 | 54 | // ======================== Effect ======================== 55 | React.useEffect(() => { 56 | if (!mergedHovering && duration > 0) { 57 | const start = Date.now() - spentTime; 58 | const timeout = setTimeout( 59 | () => { 60 | onInternalClose(); 61 | }, 62 | duration * 1000 - spentTime, 63 | ); 64 | 65 | return () => { 66 | if (pauseOnHover) { 67 | clearTimeout(timeout); 68 | } 69 | setSpentTime(Date.now() - start); 70 | }; 71 | } 72 | // eslint-disable-next-line react-hooks/exhaustive-deps 73 | }, [duration, mergedHovering, times]); 74 | 75 | React.useEffect(() => { 76 | if (!mergedHovering && mergedShowProgress && (pauseOnHover || spentTime === 0)) { 77 | const start = performance.now(); 78 | let animationFrame: number; 79 | 80 | const calculate = () => { 81 | cancelAnimationFrame(animationFrame); 82 | animationFrame = requestAnimationFrame((timestamp) => { 83 | const runtime = timestamp + spentTime - start; 84 | const progress = Math.min(runtime / (duration * 1000), 1); 85 | setPercent(progress * 100); 86 | if (progress < 1) { 87 | calculate(); 88 | } 89 | }); 90 | }; 91 | 92 | calculate(); 93 | 94 | return () => { 95 | if (pauseOnHover) { 96 | cancelAnimationFrame(animationFrame); 97 | } 98 | }; 99 | } 100 | // eslint-disable-next-line react-hooks/exhaustive-deps 101 | }, [duration, spentTime, mergedHovering, mergedShowProgress, times]); 102 | 103 | // ======================== Closable ======================== 104 | const closableObj = React.useMemo(() => { 105 | if (typeof closable === 'object' && closable !== null) { 106 | return closable; 107 | } 108 | return {}; 109 | }, [closable]); 110 | 111 | const ariaProps = pickAttrs(closableObj, true); 112 | 113 | // ======================== Progress ======================== 114 | const validPercent = 100 - (!percent || percent < 0 ? 0 : percent > 100 ? 100 : percent); 115 | 116 | // ======================== Render ======================== 117 | const noticePrefixCls = `${prefixCls}-notice`; 118 | 119 | return ( 120 |
{ 128 | setHovering(true); 129 | divProps?.onMouseEnter?.(e); 130 | }} 131 | onMouseLeave={(e) => { 132 | setHovering(false); 133 | divProps?.onMouseLeave?.(e); 134 | }} 135 | onClick={onClick} 136 | > 137 | {/* Content */} 138 |
{content}
139 | 140 | {/* Close Icon */} 141 | {closable && ( 142 | 155 | )} 156 | 157 | {/* Progress Bar */} 158 | {mergedShowProgress && ( 159 | 160 | {validPercent + '%'} 161 | 162 | )} 163 |
164 | ); 165 | }); 166 | 167 | export default Notify; 168 | -------------------------------------------------------------------------------- /src/NoticeList.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, FC } from 'react'; 2 | import React, { useContext, useEffect, useRef, useState } from 'react'; 3 | import clsx from 'classnames'; 4 | import type { CSSMotionProps } from '@rc-component/motion'; 5 | import { CSSMotionList } from '@rc-component/motion'; 6 | import type { 7 | InnerOpenConfig, 8 | NoticeConfig, 9 | OpenConfig, 10 | Placement, 11 | StackConfig, 12 | } from './interface'; 13 | import Notice from './Notice'; 14 | import { NotificationContext } from './NotificationProvider'; 15 | import useStack from './hooks/useStack'; 16 | 17 | export interface NoticeListProps { 18 | configList?: OpenConfig[]; 19 | placement?: Placement; 20 | prefixCls?: string; 21 | motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); 22 | stack?: StackConfig; 23 | 24 | // Events 25 | onAllNoticeRemoved?: (placement: Placement) => void; 26 | onNoticeClose?: (key: React.Key) => void; 27 | 28 | // Common 29 | className?: string; 30 | style?: CSSProperties; 31 | } 32 | 33 | const NoticeList: FC = (props) => { 34 | const { 35 | configList, 36 | placement, 37 | prefixCls, 38 | className, 39 | style, 40 | motion, 41 | onAllNoticeRemoved, 42 | onNoticeClose, 43 | stack: stackConfig, 44 | } = props; 45 | 46 | const { classNames: ctxCls } = useContext(NotificationContext); 47 | 48 | const dictRef = useRef>({}); 49 | const [latestNotice, setLatestNotice] = useState(null); 50 | const [hoverKeys, setHoverKeys] = useState([]); 51 | 52 | const keys = configList.map((config) => ({ 53 | config, 54 | key: String(config.key), 55 | })); 56 | 57 | const [stack, { offset, threshold, gap }] = useStack(stackConfig); 58 | 59 | const expanded = stack && (hoverKeys.length > 0 || keys.length <= threshold); 60 | 61 | const placementMotion = typeof motion === 'function' ? motion(placement) : motion; 62 | 63 | // Clean hover key 64 | useEffect(() => { 65 | if (stack && hoverKeys.length > 1) { 66 | setHoverKeys((prev) => 67 | prev.filter((key) => keys.some(({ key: dataKey }) => key === dataKey)), 68 | ); 69 | } 70 | }, [hoverKeys, keys, stack]); 71 | 72 | // Force update latest notice 73 | useEffect(() => { 74 | if (stack && dictRef.current[keys[keys.length - 1]?.key]) { 75 | setLatestNotice(dictRef.current[keys[keys.length - 1]?.key]); 76 | } 77 | }, [keys, stack]); 78 | 79 | return ( 80 | { 91 | onAllNoticeRemoved(placement); 92 | }} 93 | > 94 | {( 95 | { config, className: motionClassName, style: motionStyle, index: motionIndex }, 96 | nodeRef, 97 | ) => { 98 | const { key, times } = config as InnerOpenConfig; 99 | const strKey = String(key); 100 | const { 101 | className: configClassName, 102 | style: configStyle, 103 | classNames: configClassNames, 104 | styles: configStyles, 105 | ...restConfig 106 | } = config as NoticeConfig; 107 | const dataIndex = keys.findIndex((item) => item.key === strKey); 108 | 109 | // If dataIndex is -1, that means this notice has been removed in data, but still in dom 110 | // Should minus (motionIndex - 1) to get the correct index because keys.length is not the same as dom length 111 | const stackStyle: CSSProperties = {}; 112 | if (stack) { 113 | const index = keys.length - 1 - (dataIndex > -1 ? dataIndex : motionIndex - 1); 114 | const transformX = placement === 'top' || placement === 'bottom' ? '-50%' : '0'; 115 | if (index > 0) { 116 | stackStyle.height = expanded 117 | ? dictRef.current[strKey]?.offsetHeight 118 | : latestNotice?.offsetHeight; 119 | 120 | // Transform 121 | let verticalOffset = 0; 122 | for (let i = 0; i < index; i++) { 123 | verticalOffset += dictRef.current[keys[keys.length - 1 - i].key]?.offsetHeight + gap; 124 | } 125 | 126 | const transformY = 127 | (expanded ? verticalOffset : index * offset) * (placement.startsWith('top') ? 1 : -1); 128 | const scaleX = 129 | !expanded && latestNotice?.offsetWidth && dictRef.current[strKey]?.offsetWidth 130 | ? (latestNotice?.offsetWidth - offset * 2 * (index < 3 ? index : 3)) / 131 | dictRef.current[strKey]?.offsetWidth 132 | : 1; 133 | stackStyle.transform = `translate3d(${transformX}, ${transformY}px, 0) scaleX(${scaleX})`; 134 | } else { 135 | stackStyle.transform = `translate3d(${transformX}, 0, 0)`; 136 | } 137 | } 138 | 139 | return ( 140 |
153 | setHoverKeys((prev) => (prev.includes(strKey) ? prev : [...prev, strKey])) 154 | } 155 | onMouseLeave={() => setHoverKeys((prev) => prev.filter((k) => k !== strKey))} 156 | > 157 | { 160 | if (dataIndex > -1) { 161 | dictRef.current[strKey] = node; 162 | } else { 163 | delete dictRef.current[strKey]; 164 | } 165 | }} 166 | prefixCls={prefixCls} 167 | classNames={configClassNames} 168 | styles={configStyles} 169 | className={clsx(configClassName, ctxCls?.notice)} 170 | style={configStyle} 171 | times={times} 172 | key={key} 173 | eventKey={key} 174 | onNoticeClose={onNoticeClose} 175 | hovering={stack && hoverKeys.length > 0} 176 | /> 177 |
178 | ); 179 | }} 180 |
181 | ); 182 | }; 183 | 184 | if (process.env.NODE_ENV !== 'production') { 185 | NoticeList.displayName = 'NoticeList'; 186 | } 187 | 188 | export default NoticeList; 189 | -------------------------------------------------------------------------------- /src/NotificationProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import React from 'react'; 3 | 4 | export interface NotificationContextProps { 5 | classNames?: { 6 | notice?: string; 7 | list?: string; 8 | }; 9 | } 10 | 11 | export const NotificationContext = React.createContext({}); 12 | 13 | export interface NotificationProviderProps extends NotificationContextProps { 14 | children: React.ReactNode; 15 | } 16 | 17 | const NotificationProvider: FC = ({ children, classNames }) => { 18 | return ( 19 | {children} 20 | ); 21 | }; 22 | 23 | export default NotificationProvider; 24 | -------------------------------------------------------------------------------- /src/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactElement } from 'react'; 3 | import { createPortal } from 'react-dom'; 4 | import type { CSSMotionProps } from '@rc-component/motion'; 5 | import type { InnerOpenConfig, OpenConfig, Placement, Placements, StackConfig } from './interface'; 6 | import NoticeList from './NoticeList'; 7 | 8 | export interface NotificationsProps { 9 | prefixCls?: string; 10 | motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); 11 | container?: HTMLElement | ShadowRoot; 12 | maxCount?: number; 13 | className?: (placement: Placement) => string; 14 | style?: (placement: Placement) => React.CSSProperties; 15 | onAllRemoved?: VoidFunction; 16 | stack?: StackConfig; 17 | renderNotifications?: ( 18 | node: ReactElement, 19 | info: { prefixCls: string; key: React.Key }, 20 | ) => ReactElement; 21 | } 22 | 23 | export interface NotificationsRef { 24 | open: (config: OpenConfig) => void; 25 | close: (key: React.Key) => void; 26 | destroy: () => void; 27 | } 28 | 29 | // ant-notification ant-notification-topRight 30 | const Notifications = React.forwardRef((props, ref) => { 31 | const { 32 | prefixCls = 'rc-notification', 33 | container, 34 | motion, 35 | maxCount, 36 | className, 37 | style, 38 | onAllRemoved, 39 | stack, 40 | renderNotifications, 41 | } = props; 42 | const [configList, setConfigList] = React.useState([]); 43 | 44 | // ======================== Close ========================= 45 | const onNoticeClose = (key: React.Key) => { 46 | // Trigger close event 47 | const config = configList.find((item) => item.key === key); 48 | config?.onClose?.(); 49 | 50 | setConfigList((list) => list.filter((item) => item.key !== key)); 51 | }; 52 | 53 | // ========================= Refs ========================= 54 | React.useImperativeHandle(ref, () => ({ 55 | open: (config) => { 56 | setConfigList((list) => { 57 | let clone = [...list]; 58 | 59 | // Replace if exist 60 | const index = clone.findIndex((item) => item.key === config.key); 61 | const innerConfig: InnerOpenConfig = { ...config }; 62 | if (index >= 0) { 63 | innerConfig.times = ((list[index] as InnerOpenConfig)?.times || 0) + 1; 64 | clone[index] = innerConfig; 65 | } else { 66 | innerConfig.times = 0; 67 | clone.push(innerConfig); 68 | } 69 | 70 | if (maxCount > 0 && clone.length > maxCount) { 71 | clone = clone.slice(-maxCount); 72 | } 73 | 74 | return clone; 75 | }); 76 | }, 77 | close: (key) => { 78 | onNoticeClose(key); 79 | }, 80 | destroy: () => { 81 | setConfigList([]); 82 | }, 83 | })); 84 | 85 | // ====================== Placements ====================== 86 | const [placements, setPlacements] = React.useState({}); 87 | 88 | React.useEffect(() => { 89 | const nextPlacements: Placements = {}; 90 | 91 | configList.forEach((config) => { 92 | const { placement = 'topRight' } = config; 93 | 94 | if (placement) { 95 | nextPlacements[placement] = nextPlacements[placement] || []; 96 | nextPlacements[placement].push(config); 97 | } 98 | }); 99 | 100 | // Fill exist placements to avoid empty list causing remove without motion 101 | Object.keys(placements).forEach((placement) => { 102 | nextPlacements[placement] = nextPlacements[placement] || []; 103 | }); 104 | 105 | setPlacements(nextPlacements); 106 | }, [configList]); 107 | 108 | // Clean up container if all notices fade out 109 | const onAllNoticeRemoved = (placement: Placement) => { 110 | setPlacements((originPlacements) => { 111 | const clone = { 112 | ...originPlacements, 113 | }; 114 | const list = clone[placement] || []; 115 | 116 | if (!list.length) { 117 | delete clone[placement]; 118 | } 119 | 120 | return clone; 121 | }); 122 | }; 123 | 124 | // Effect tell that placements is empty now 125 | const emptyRef = React.useRef(false); 126 | React.useEffect(() => { 127 | if (Object.keys(placements).length > 0) { 128 | emptyRef.current = true; 129 | } else if (emptyRef.current) { 130 | // Trigger only when from exist to empty 131 | onAllRemoved?.(); 132 | emptyRef.current = false; 133 | } 134 | }, [placements]); 135 | // ======================== Render ======================== 136 | if (!container) { 137 | return null; 138 | } 139 | 140 | const placementList = Object.keys(placements) as Placement[]; 141 | 142 | return createPortal( 143 | <> 144 | {placementList.map((placement) => { 145 | const placementConfigList = placements[placement]; 146 | 147 | const list = ( 148 | 160 | ); 161 | 162 | return renderNotifications 163 | ? renderNotifications(list, { prefixCls, key: placement }) 164 | : list; 165 | })} 166 | , 167 | container, 168 | ); 169 | }); 170 | 171 | if (process.env.NODE_ENV !== 'production') { 172 | Notifications.displayName = 'Notifications'; 173 | } 174 | 175 | export default Notifications; 176 | -------------------------------------------------------------------------------- /src/hooks/useNotification.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSMotionProps } from '@rc-component/motion'; 2 | import * as React from 'react'; 3 | import type { NotificationsProps, NotificationsRef } from '../Notifications'; 4 | import Notifications from '../Notifications'; 5 | import type { OpenConfig, Placement, StackConfig } from '../interface'; 6 | import { useEvent } from '@rc-component/util'; 7 | 8 | const defaultGetContainer = () => document.body; 9 | 10 | type OptionalConfig = Partial; 11 | 12 | export interface NotificationConfig { 13 | prefixCls?: string; 14 | /** Customize container. It will repeat call which means you should return same container element. */ 15 | getContainer?: () => HTMLElement | ShadowRoot; 16 | motion?: CSSMotionProps | ((placement: Placement) => CSSMotionProps); 17 | 18 | closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes); 19 | maxCount?: number; 20 | duration?: number; 21 | showProgress?: boolean; 22 | pauseOnHover?: boolean; 23 | /** @private. Config for notification holder style. Safe to remove if refactor */ 24 | className?: (placement: Placement) => string; 25 | /** @private. Config for notification holder style. Safe to remove if refactor */ 26 | style?: (placement: Placement) => React.CSSProperties; 27 | /** @private Trigger when all the notification closed. */ 28 | onAllRemoved?: VoidFunction; 29 | stack?: StackConfig; 30 | /** @private Slot for style in Notifications */ 31 | renderNotifications?: NotificationsProps['renderNotifications']; 32 | } 33 | 34 | export interface NotificationAPI { 35 | open: (config: OptionalConfig) => void; 36 | close: (key: React.Key) => void; 37 | destroy: () => void; 38 | } 39 | 40 | interface OpenTask { 41 | type: 'open'; 42 | config: OpenConfig; 43 | } 44 | 45 | interface CloseTask { 46 | type: 'close'; 47 | key: React.Key; 48 | } 49 | 50 | interface DestroyTask { 51 | type: 'destroy'; 52 | } 53 | 54 | type Task = OpenTask | CloseTask | DestroyTask; 55 | 56 | let uniqueKey = 0; 57 | 58 | function mergeConfig(...objList: Partial[]): T { 59 | const clone: T = {} as T; 60 | 61 | objList.forEach((obj) => { 62 | if (obj) { 63 | Object.keys(obj).forEach((key) => { 64 | const val = obj[key]; 65 | 66 | if (val !== undefined) { 67 | clone[key] = val; 68 | } 69 | }); 70 | } 71 | }); 72 | 73 | return clone; 74 | } 75 | 76 | export default function useNotification( 77 | rootConfig: NotificationConfig = {}, 78 | ): [NotificationAPI, React.ReactElement] { 79 | const { 80 | getContainer = defaultGetContainer, 81 | motion, 82 | prefixCls, 83 | maxCount, 84 | className, 85 | style, 86 | onAllRemoved, 87 | stack, 88 | renderNotifications, 89 | ...shareConfig 90 | } = rootConfig; 91 | 92 | const [container, setContainer] = React.useState(); 93 | const notificationsRef = React.useRef(); 94 | const contextHolder = ( 95 | 107 | ); 108 | 109 | const [taskQueue, setTaskQueue] = React.useState([]); 110 | 111 | const open = useEvent((config) => { 112 | const mergedConfig = mergeConfig(shareConfig, config); 113 | if (mergedConfig.key === null || mergedConfig.key === undefined) { 114 | mergedConfig.key = `rc-notification-${uniqueKey}`; 115 | uniqueKey += 1; 116 | } 117 | 118 | setTaskQueue((queue) => [...queue, { type: 'open', config: mergedConfig }]); 119 | }); 120 | 121 | // ========================= Refs ========================= 122 | const api = React.useMemo( 123 | () => ({ 124 | open: open, 125 | close: (key) => { 126 | setTaskQueue((queue) => [...queue, { type: 'close', key }]); 127 | }, 128 | destroy: () => { 129 | setTaskQueue((queue) => [...queue, { type: 'destroy' }]); 130 | }, 131 | }), 132 | [], 133 | ); 134 | 135 | // ======================= Container ====================== 136 | // React 18 should all in effect that we will check container in each render 137 | // Which means getContainer should be stable. 138 | React.useEffect(() => { 139 | setContainer(getContainer()); 140 | }); 141 | 142 | // ======================== Effect ======================== 143 | React.useEffect(() => { 144 | // Flush task when node ready 145 | if (notificationsRef.current && taskQueue.length) { 146 | taskQueue.forEach((task) => { 147 | switch (task.type) { 148 | case 'open': 149 | notificationsRef.current.open(task.config); 150 | break; 151 | 152 | case 'close': 153 | notificationsRef.current.close(task.key); 154 | break; 155 | 156 | case 'destroy': 157 | notificationsRef.current.destroy(); 158 | break; 159 | } 160 | }); 161 | 162 | // https://github.com/ant-design/ant-design/issues/52590 163 | // React `startTransition` will run once `useEffect` but many times `setState`, 164 | // So `setTaskQueue` with filtered array will cause infinite loop. 165 | // We cache the first match queue instead. 166 | let oriTaskQueue: Task[]; 167 | let tgtTaskQueue: Task[]; 168 | 169 | // React 17 will mix order of effect & setState in async 170 | // - open: setState[0] 171 | // - effect[0] 172 | // - open: setState[1] 173 | // - effect setState([]) * here will clean up [0, 1] in React 17 174 | setTaskQueue((oriQueue) => { 175 | if (oriTaskQueue !== oriQueue || !tgtTaskQueue) { 176 | oriTaskQueue = oriQueue; 177 | tgtTaskQueue = oriQueue.filter((task) => !taskQueue.includes(task)); 178 | } 179 | 180 | return tgtTaskQueue; 181 | }); 182 | } 183 | }, [taskQueue]); 184 | 185 | // ======================== Return ======================== 186 | return [api, contextHolder]; 187 | } 188 | -------------------------------------------------------------------------------- /src/hooks/useStack.ts: -------------------------------------------------------------------------------- 1 | import type { StackConfig } from '../interface'; 2 | 3 | const DEFAULT_OFFSET = 8; 4 | const DEFAULT_THRESHOLD = 3; 5 | const DEFAULT_GAP = 16; 6 | 7 | type StackParams = Exclude; 8 | 9 | type UseStack = (config?: StackConfig) => [boolean, StackParams]; 10 | 11 | const useStack: UseStack = (config) => { 12 | const result: StackParams = { 13 | offset: DEFAULT_OFFSET, 14 | threshold: DEFAULT_THRESHOLD, 15 | gap: DEFAULT_GAP, 16 | }; 17 | if (config && typeof config === 'object') { 18 | result.offset = config.offset ?? DEFAULT_OFFSET; 19 | result.threshold = config.threshold ?? DEFAULT_THRESHOLD; 20 | result.gap = config.gap ?? DEFAULT_GAP; 21 | } 22 | return [!!config, result]; 23 | }; 24 | 25 | export default useStack; 26 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import useNotification from './hooks/useNotification'; 2 | import Notice from './Notice'; 3 | import type { NotificationAPI, NotificationConfig } from './hooks/useNotification'; 4 | import NotificationProvider from './NotificationProvider'; 5 | 6 | export { useNotification, Notice, NotificationProvider }; 7 | export type { NotificationAPI, NotificationConfig }; 8 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | 3 | export type Placement = 'top' | 'topLeft' | 'topRight' | 'bottom' | 'bottomLeft' | 'bottomRight'; 4 | 5 | type NoticeSemanticProps = 'wrapper'; 6 | 7 | export interface NoticeConfig { 8 | content?: React.ReactNode; 9 | duration?: number | null; 10 | showProgress?: boolean; 11 | pauseOnHover?: boolean; 12 | 13 | closable?: boolean | ({ closeIcon?: React.ReactNode } & React.AriaAttributes); 14 | className?: string; 15 | style?: React.CSSProperties; 16 | classNames?: { 17 | [key in NoticeSemanticProps]?: string; 18 | }; 19 | styles?: { 20 | [key in NoticeSemanticProps]?: React.CSSProperties; 21 | }; 22 | /** @private Internal usage. Do not override in your code */ 23 | props?: React.HTMLAttributes & Record; 24 | 25 | onClose?: VoidFunction; 26 | onClick?: React.MouseEventHandler; 27 | } 28 | 29 | export interface OpenConfig extends NoticeConfig { 30 | key: React.Key; 31 | placement?: Placement; 32 | content?: React.ReactNode; 33 | duration?: number | null; 34 | } 35 | 36 | export type InnerOpenConfig = OpenConfig & { times?: number }; 37 | 38 | export type Placements = Partial>; 39 | 40 | export type StackConfig = 41 | | boolean 42 | | { 43 | /** 44 | * When number is greater than threshold, notifications will be stacked together. 45 | * @default 3 46 | */ 47 | threshold?: number; 48 | /** 49 | * Offset when notifications are stacked together. 50 | * @default 8 51 | */ 52 | offset?: number; 53 | /** 54 | * Spacing between each notification when expanded. 55 | */ 56 | gap?: number; 57 | }; 58 | -------------------------------------------------------------------------------- /tests/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react'; 2 | import { render, fireEvent, act } from '@testing-library/react'; 3 | import { useNotification } from '../src'; 4 | import type { NotificationAPI, NotificationConfig } from '../src'; 5 | import NotificationProvider from '../src/NotificationProvider'; 6 | 7 | require('../assets/index.less'); 8 | 9 | describe('Notification.Hooks', () => { 10 | beforeEach(() => { 11 | vi.useFakeTimers(); 12 | }); 13 | 14 | afterEach(() => { 15 | vi.useRealTimers(); 16 | }); 17 | 18 | function renderDemo(config?: NotificationConfig) { 19 | let instance: NotificationAPI; 20 | 21 | const Demo = () => { 22 | const [api, holder] = useNotification(config); 23 | instance = api; 24 | 25 | return holder; 26 | }; 27 | 28 | const renderResult = render(); 29 | 30 | return { ...renderResult, instance }; 31 | } 32 | 33 | it('works', async () => { 34 | const Context = React.createContext({ name: 'light' }); 35 | 36 | const Demo = () => { 37 | const [api, holder] = useNotification(); 38 | return ( 39 | 40 | 162 |

163 | ), 164 | key, 165 | duration: null, 166 | }); 167 | }); 168 | 169 | expect(document.querySelectorAll('.test')).toHaveLength(1); 170 | fireEvent.click(document.querySelector('#closeButton')); 171 | 172 | act(() => { 173 | vi.runAllTimers(); 174 | }); 175 | 176 | expect(document.querySelectorAll('.test')).toHaveLength(0); 177 | unmount(); 178 | }); 179 | 180 | it('update notification by key with multi instance', () => { 181 | const { instance } = renderDemo(); 182 | 183 | const key = 'updatable'; 184 | const value = 'value'; 185 | const newValue = `new-${value}`; 186 | const notUpdatableValue = 'not-updatable-value'; 187 | 188 | act(() => { 189 | instance.open({ 190 | content: ( 191 |

192 | {notUpdatableValue} 193 |

194 | ), 195 | duration: null, 196 | }); 197 | }); 198 | 199 | act(() => { 200 | instance.open({ 201 | content: ( 202 |

203 | {value} 204 |

205 | ), 206 | key, 207 | duration: null, 208 | }); 209 | }); 210 | 211 | expect(document.querySelectorAll('.updatable')).toHaveLength(1); 212 | expect(document.querySelector('.updatable').textContent).toEqual(value); 213 | 214 | act(() => { 215 | instance.open({ 216 | content: ( 217 |

218 | {newValue} 219 |

220 | ), 221 | key, 222 | duration: 0.1, 223 | }); 224 | }); 225 | 226 | // Text updated successfully 227 | expect(document.querySelectorAll('.updatable')).toHaveLength(1); 228 | expect(document.querySelector('.updatable').textContent).toEqual(newValue); 229 | 230 | act(() => { 231 | vi.runAllTimers(); 232 | }); 233 | 234 | // Other notices are not affected 235 | expect(document.querySelectorAll('.not-updatable')).toHaveLength(1); 236 | expect(document.querySelector('.not-updatable').textContent).toEqual(notUpdatableValue); 237 | 238 | // Duration updated successfully 239 | expect(document.querySelectorAll('.updatable')).toHaveLength(0); 240 | }); 241 | 242 | it('freeze notification layer when mouse over', () => { 243 | const { instance } = renderDemo(); 244 | 245 | act(() => { 246 | instance.open({ 247 | content: ( 248 |

249 | freeze 250 |

251 | ), 252 | duration: 0.3, 253 | }); 254 | }); 255 | 256 | expect(document.querySelectorAll('.freeze')).toHaveLength(1); 257 | 258 | // Mouse in should not remove 259 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); 260 | act(() => { 261 | vi.runAllTimers(); 262 | }); 263 | expect(document.querySelectorAll('.freeze')).toHaveLength(1); 264 | 265 | // Mouse out will remove 266 | fireEvent.mouseLeave(document.querySelector('.rc-notification-notice')); 267 | act(() => { 268 | vi.runAllTimers(); 269 | }); 270 | expect(document.querySelectorAll('.freeze')).toHaveLength(0); 271 | }); 272 | 273 | it('continue timing after hover', () => { 274 | const { instance } = renderDemo({ 275 | duration: 1, 276 | }); 277 | 278 | act(() => { 279 | instance.open({ 280 | content:

1

, 281 | }); 282 | }); 283 | 284 | expect(document.querySelector('.test')).toBeTruthy(); 285 | 286 | // Wait for 500ms 287 | act(() => { 288 | vi.advanceTimersByTime(500); 289 | }); 290 | expect(document.querySelector('.test')).toBeTruthy(); 291 | 292 | // Mouse in should not remove 293 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); 294 | act(() => { 295 | vi.advanceTimersByTime(1000); 296 | }); 297 | expect(document.querySelector('.test')).toBeTruthy(); 298 | 299 | // Mouse out should not remove until 500ms later 300 | fireEvent.mouseLeave(document.querySelector('.rc-notification-notice')); 301 | act(() => { 302 | vi.advanceTimersByTime(450); 303 | }); 304 | expect(document.querySelector('.test')).toBeTruthy(); 305 | 306 | act(() => { 307 | vi.advanceTimersByTime(100); 308 | }); 309 | expect(document.querySelector('.test')).toBeFalsy(); 310 | }); 311 | 312 | describe('pauseOnHover is false', () => { 313 | it('does not freeze when pauseOnHover is false', () => { 314 | const { instance } = renderDemo(); 315 | 316 | act(() => { 317 | instance.open({ 318 | content: ( 319 |

320 | not freeze 321 |

322 | ), 323 | duration: 0.3, 324 | pauseOnHover: false, 325 | }); 326 | }); 327 | 328 | expect(document.querySelectorAll('.not-freeze')).toHaveLength(1); 329 | 330 | // Mouse in should remove 331 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); 332 | act(() => { 333 | vi.runAllTimers(); 334 | }); 335 | expect(document.querySelectorAll('.not-freeze')).toHaveLength(0); 336 | }); 337 | 338 | it('continue timing after hover', () => { 339 | const { instance } = renderDemo({ 340 | duration: 1, 341 | pauseOnHover: false, 342 | }); 343 | 344 | act(() => { 345 | instance.open({ 346 | content:

1

, 347 | }); 348 | }); 349 | 350 | expect(document.querySelector('.test')).toBeTruthy(); 351 | 352 | // Wait for 500ms 353 | act(() => { 354 | vi.advanceTimersByTime(500); 355 | }); 356 | expect(document.querySelector('.test')).toBeTruthy(); 357 | 358 | // Mouse in should not remove 359 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice')); 360 | act(() => { 361 | vi.advanceTimersByTime(200); 362 | }); 363 | expect(document.querySelector('.test')).toBeTruthy(); 364 | 365 | // Mouse out should not remove until 500ms later 366 | fireEvent.mouseLeave(document.querySelector('.rc-notification-notice')); 367 | act(() => { 368 | vi.advanceTimersByTime(200); 369 | }); 370 | expect(document.querySelector('.test')).toBeTruthy(); 371 | 372 | // 373 | act(() => { 374 | vi.advanceTimersByTime(100); 375 | }); 376 | expect(document.querySelector('.test')).toBeFalsy(); 377 | }); 378 | }); 379 | 380 | describe('maxCount', () => { 381 | it('remove work when maxCount set', () => { 382 | const { instance } = renderDemo({ 383 | maxCount: 1, 384 | }); 385 | 386 | // First 387 | act(() => { 388 | instance.open({ 389 | content:
bamboo
, 390 | key: 'bamboo', 391 | duration: 0, 392 | }); 393 | }); 394 | 395 | // Next 396 | act(() => { 397 | instance.open({ 398 | content:
bamboo
, 399 | key: 'bamboo', 400 | duration: 0, 401 | }); 402 | }); 403 | expect(document.querySelectorAll('.max-count')).toHaveLength(1); 404 | 405 | act(() => { 406 | instance.close('bamboo'); 407 | }); 408 | expect(document.querySelectorAll('.max-count')).toHaveLength(0); 409 | }); 410 | 411 | it('drop first notice when items limit exceeds', () => { 412 | const { instance } = renderDemo({ 413 | maxCount: 1, 414 | }); 415 | 416 | const value = 'updated last'; 417 | act(() => { 418 | instance.open({ 419 | content: simple show, 420 | duration: 0, 421 | }); 422 | }); 423 | 424 | act(() => { 425 | instance.open({ 426 | content: simple show, 427 | duration: 0, 428 | }); 429 | }); 430 | 431 | act(() => { 432 | instance.open({ 433 | content: {value}, 434 | duration: 0, 435 | }); 436 | }); 437 | 438 | act(() => { 439 | vi.runAllTimers(); 440 | }); 441 | 442 | expect(document.querySelectorAll('.test-maxcount')).toHaveLength(1); 443 | expect(document.querySelector('.test-maxcount').textContent).toEqual(value); 444 | }); 445 | 446 | it('duration should work', () => { 447 | const { instance } = renderDemo({ 448 | maxCount: 1, 449 | }); 450 | 451 | act(() => { 452 | instance.open({ 453 | content: bamboo, 454 | duration: 99, 455 | }); 456 | }); 457 | expect(document.querySelector('.auto-remove').textContent).toEqual('bamboo'); 458 | 459 | act(() => { 460 | instance.open({ 461 | content: light, 462 | duration: 0.5, 463 | }); 464 | }); 465 | expect(document.querySelector('.auto-remove').textContent).toEqual('light'); 466 | 467 | act(() => { 468 | vi.runAllTimers(); 469 | }); 470 | expect(document.querySelectorAll('.auto-remove')).toHaveLength(0); 471 | }); 472 | }); 473 | 474 | it('onClick trigger', () => { 475 | const { instance } = renderDemo(); 476 | let clicked = 0; 477 | 478 | const key = Date.now(); 479 | const close = (k: React.Key) => { 480 | instance.close(k); 481 | }; 482 | 483 | act(() => { 484 | instance.open({ 485 | content: ( 486 |

487 | 490 |

491 | ), 492 | key, 493 | duration: null, 494 | onClick: () => { 495 | clicked += 1; 496 | }, 497 | }); 498 | }); 499 | 500 | fireEvent.click(document.querySelector('.rc-notification-notice')); // origin latest 501 | expect(clicked).toEqual(1); 502 | }); 503 | 504 | it('Close Notification only trigger onClose', () => { 505 | const { instance } = renderDemo(); 506 | let clickCount = 0; 507 | let closeCount = 0; 508 | 509 | act(() => { 510 | instance.open({ 511 | content:

1

, 512 | closable: true, 513 | onClick: () => { 514 | clickCount += 1; 515 | }, 516 | onClose: () => { 517 | closeCount += 1; 518 | }, 519 | }); 520 | }); 521 | 522 | fireEvent.click(document.querySelector('.rc-notification-notice-close')); // origin latest 523 | expect(clickCount).toEqual(0); 524 | expect(closeCount).toEqual(1); 525 | }); 526 | 527 | it('sets data attributes', () => { 528 | const { instance } = renderDemo(); 529 | 530 | act(() => { 531 | instance.open({ 532 | content: simple show, 533 | duration: 3, 534 | className: 'notice-class', 535 | props: { 536 | 'data-test': 'data-test-value', 537 | 'data-testid': 'data-testid-value', 538 | }, 539 | }); 540 | }); 541 | 542 | const notice = document.querySelectorAll('.notice-class'); 543 | expect(notice.length).toBe(1); 544 | 545 | expect(notice[0].getAttribute('data-test')).toBe('data-test-value'); 546 | expect(notice[0].getAttribute('data-testid')).toBe('data-testid-value'); 547 | }); 548 | 549 | it('sets aria attributes', () => { 550 | const { instance } = renderDemo(); 551 | 552 | act(() => { 553 | instance.open({ 554 | content: simple show, 555 | duration: 3, 556 | className: 'notice-class', 557 | props: { 558 | 'aria-describedby': 'aria-describedby-value', 559 | 'aria-labelledby': 'aria-labelledby-value', 560 | }, 561 | }); 562 | }); 563 | 564 | const notice = document.querySelectorAll('.notice-class'); 565 | expect(notice.length).toBe(1); 566 | expect(notice[0].getAttribute('aria-describedby')).toBe('aria-describedby-value'); 567 | expect(notice[0].getAttribute('aria-labelledby')).toBe('aria-labelledby-value'); 568 | }); 569 | 570 | it('sets role attribute', () => { 571 | const { instance } = renderDemo(); 572 | 573 | act(() => { 574 | instance.open({ 575 | content: simple show, 576 | duration: 3, 577 | className: 'notice-class', 578 | props: { role: 'alert' }, 579 | }); 580 | }); 581 | 582 | const notice = document.querySelectorAll('.notice-class'); 583 | expect(notice.length).toBe(1); 584 | expect(notice[0].getAttribute('role')).toBe('alert'); 585 | }); 586 | 587 | it('should style work', () => { 588 | const { instance } = renderDemo({ 589 | style: () => ({ 590 | content: 'little', 591 | }), 592 | }); 593 | 594 | act(() => { 595 | instance.open({}); 596 | }); 597 | 598 | expect(document.querySelector('.rc-notification')).toHaveStyle({ 599 | content: 'little', 600 | }); 601 | }); 602 | 603 | it('should open style and className work', () => { 604 | const { instance } = renderDemo(); 605 | 606 | act(() => { 607 | instance.open({ 608 | style: { 609 | content: 'little', 610 | }, 611 | className: 'bamboo', 612 | }); 613 | }); 614 | 615 | expect(document.querySelector('.rc-notification-notice')).toHaveStyle({ 616 | content: 'little', 617 | }); 618 | expect(document.querySelector('.rc-notification-notice')).toHaveClass('bamboo'); 619 | }); 620 | 621 | it('should open styles and classNames work', () => { 622 | const { instance } = renderDemo(); 623 | 624 | act(() => { 625 | instance.open({ 626 | styles: { 627 | wrapper: { 628 | content: 'little', 629 | }, 630 | }, 631 | classNames: { 632 | wrapper: 'bamboo', 633 | }, 634 | }); 635 | }); 636 | 637 | expect(document.querySelector('.rc-notification-notice-wrapper')).toHaveStyle({ 638 | content: 'little', 639 | }); 640 | expect(document.querySelector('.rc-notification-notice-wrapper')).toHaveClass('bamboo'); 641 | }); 642 | 643 | it('should className work', () => { 644 | const { instance } = renderDemo({ 645 | className: (placement) => `bamboo-${placement}`, 646 | }); 647 | 648 | act(() => { 649 | instance.open({}); 650 | }); 651 | 652 | expect(document.querySelector('.bamboo-topRight')).toBeTruthy(); 653 | }); 654 | 655 | it('placement', () => { 656 | const { instance } = renderDemo(); 657 | 658 | act(() => { 659 | instance.open({ 660 | placement: 'bottomLeft', 661 | }); 662 | }); 663 | 664 | expect(document.querySelector('.rc-notification')).toHaveClass('rc-notification-bottomLeft'); 665 | }); 666 | 667 | it('motion as function', () => { 668 | const motionFn = vi.fn(); 669 | 670 | const { instance } = renderDemo({ 671 | motion: motionFn, 672 | }); 673 | 674 | act(() => { 675 | instance.open({ 676 | placement: 'bottomLeft', 677 | }); 678 | }); 679 | 680 | expect(motionFn).toHaveBeenCalledWith('bottomLeft'); 681 | }); 682 | 683 | it('notice when empty', () => { 684 | const onAllRemoved = vi.fn(); 685 | 686 | const { instance } = renderDemo({ 687 | onAllRemoved, 688 | }); 689 | 690 | expect(onAllRemoved).not.toHaveBeenCalled(); 691 | 692 | // Open! 693 | act(() => { 694 | instance.open({ 695 | duration: 0.1, 696 | }); 697 | }); 698 | expect(onAllRemoved).not.toHaveBeenCalled(); 699 | 700 | // Hide 701 | act(() => { 702 | vi.runAllTimers(); 703 | }); 704 | expect(onAllRemoved).toHaveBeenCalled(); 705 | 706 | // Open again 707 | onAllRemoved.mockReset(); 708 | 709 | act(() => { 710 | instance.open({ 711 | duration: 0, 712 | key: 'first', 713 | }); 714 | }); 715 | 716 | act(() => { 717 | instance.open({ 718 | duration: 0, 719 | key: 'second', 720 | }); 721 | }); 722 | 723 | expect(onAllRemoved).not.toHaveBeenCalled(); 724 | 725 | // Close first 726 | act(() => { 727 | instance.close('first'); 728 | }); 729 | expect(onAllRemoved).not.toHaveBeenCalled(); 730 | 731 | // Close second 732 | act(() => { 733 | instance.close('second'); 734 | }); 735 | expect(onAllRemoved).toHaveBeenCalled(); 736 | }); 737 | 738 | it('when the same key message is closing, dont open new until it closed', () => { 739 | const onClose = vi.fn(); 740 | const Demo = () => { 741 | const [api, holder] = useNotification(); 742 | return ( 743 | <> 744 | 864 | 874 | {holder} 875 | 876 | ); 877 | }; 878 | 879 | const { getByTestId } = render(); 880 | 881 | fireEvent.click(getByTestId('show-notification')); 882 | 883 | expect(document.querySelectorAll('.rc-notification-notice').length).toBe(1); 884 | fireEvent.click(getByTestId('change-duration')); 885 | fireEvent.click(getByTestId('show-notification')); 886 | expect(document.querySelectorAll('.rc-notification-notice').length).toBe(2); 887 | 888 | act(() => { 889 | vi.advanceTimersByTime(5000); 890 | }); 891 | 892 | expect(document.querySelectorAll('.rc-notification-notice').length).toBe(1); 893 | }); 894 | }); 895 | it('notification close node ', () => { 896 | const Demo = () => { 897 | const [duration] = React.useState(0); 898 | const [api, holder] = useNotification({ duration }); 899 | return ( 900 | <> 901 | 912 | {holder} 913 | 914 | ); 915 | }; 916 | const { getByTestId } = render(); 917 | fireEvent.click(getByTestId('show-notification')); 918 | expect(document.querySelector('button.rc-notification-notice-close')).toHaveAttribute( 919 | 'aria-label', 920 | 'xxx', 921 | ); 922 | }); 923 | }); 924 | -------------------------------------------------------------------------------- /tests/stack.test.tsx: -------------------------------------------------------------------------------- 1 | import { useNotification } from '../src'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import React from 'react'; 4 | 5 | require('../assets/index.less'); 6 | 7 | describe('stack', () => { 8 | it('support stack', () => { 9 | const Demo = () => { 10 | const [api, holder] = useNotification({ 11 | stack: { threshold: 3 }, 12 | }); 13 | return ( 14 | <> 15 |