├── .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] [](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 | [](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](http://godban.github.io/browsers-support-badges/) | last 2 versions |
39 | | [ Chrome](http://godban.github.io/browsers-support-badges/) | last 2 versions |
40 | | [ Safari](http://godban.github.io/browsers-support-badges/) | last 2 versions |
41 | | [ 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 | name
59 | type
60 | default
61 | description
62 |
63 |
64 |
65 |
66 | prefixCls
67 | String
68 |
69 | prefix class name for notification container
70 |
71 |
72 | style
73 | Object
74 | {'top': 65, left: '50%'}
75 | additional style for notification container.
76 |
77 |
78 | getContainer
79 | getContainer(): HTMLElement
80 |
81 | function returning html node which will act as notification container
82 |
83 |
84 | maxCount
85 | number
86 |
87 | max notices show, drop first notice if exceed limit
88 |
89 |
90 |
91 |
92 | ### notification.notice(props)
93 |
94 | props details:
95 |
96 |
97 |
98 |
99 | name
100 | type
101 | default
102 | description
103 |
104 |
105 |
106 |
107 | content
108 | React.Element
109 |
110 | content of notice
111 |
112 |
113 | key
114 | String
115 |
116 | id of this notice
117 |
118 |
119 | closable
120 | Boolean
121 |
122 | whether show close button
123 |
124 |
125 | onClose
126 | Function
127 |
128 | called when notice close
129 |
130 |
131 | duration
132 | number
133 | 1.5
134 | after duration of time, this notice will disappear.(seconds)
135 |
136 |
137 | showProgress
138 | boolean
139 | false
140 | show with progress bar for auto-closing notification
141 |
142 |
143 | pauseOnHover
144 | boolean
145 | true
146 | keep the timer running or not on hover
147 |
148 |
149 | style
150 | Object
151 | { right: '50%' }
152 | additional style for single notice node.
153 |
154 |
155 | closeIcon
156 | ReactNode
157 |
158 | specific the close icon.
159 |
160 |
161 | props
162 | Object
163 |
164 | An 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.
165 |
166 |
167 |
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 | {
25 | open({
26 | ...NOTICE,
27 | content: {({ name }) => `Hi ${name}!`} ,
28 | props: {
29 | 'data-testid': 'my-data-testid',
30 | },
31 | });
32 | }}
33 | >
34 | simple show
35 |
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 | {
17 | notice.open({
18 | content: `${new Date().toISOString()}`,
19 | });
20 | }}
21 | >
22 | Basic
23 |
24 |
25 | {/* Not Close */}
26 | {
28 | notice.open({
29 | content: `${Array(Math.round(Math.random() * 5) + 1)
30 | .fill(1)
31 | .map(() => new Date().toISOString())
32 | .join('\n')}`,
33 | duration: null,
34 | });
35 | }}
36 | >
37 | Not Auto Close
38 |
39 |
40 | {/* Not Close */}
41 | {
43 | notice.open({
44 | content: `${Array(5)
45 | .fill(1)
46 | .map(() => new Date().toISOString())
47 | .join('\n')}`,
48 | duration: null,
49 | });
50 | }}
51 | >
52 | Not Auto Close
53 |
54 |
55 |
56 |
57 | {/* No Closable */}
58 | {
60 | notice.open({
61 | content: `No Close! ${new Date().toISOString()}`,
62 | duration: null,
63 | closable: false,
64 | key: 'No Close',
65 | onClose: () => {
66 | console.log('Close!!!');
67 | },
68 | });
69 | }}
70 | >
71 | No Closable
72 |
73 |
74 | {/* Force Close */}
75 | {
77 | notice.close('No Close');
78 | }}
79 | >
80 | Force Close No Closable
81 |
82 |
83 |
84 |
85 |
86 | {/* Destroy All */}
87 | {
89 | notice.destroy();
90 | }}
91 | >
92 | Destroy All
93 |
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 | {
14 | notice.open({
15 | content: `${new Date().toISOString()}`,
16 | });
17 | }}
18 | >
19 | Max Count 3
20 |
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 | {
14 | notice.open({
15 | content: `${new Date().toISOString()}`,
16 | });
17 | }}
18 | >
19 | Show With Progress
20 |
21 | {
23 | notice.open({
24 | content: `${new Date().toISOString()}`,
25 | pauseOnHover: false,
26 | });
27 | }}
28 | >
29 | Not Pause On Hover
30 |
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 | {
25 | open(getConfig());
26 | }}
27 | >
28 | Top Right
29 |
30 | {
33 | open({ ...getConfig(), placement: 'bottomRight' });
34 | }}
35 | >
36 | Bottom Right
37 |
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 |
{
148 | e.preventDefault();
149 | e.stopPropagation();
150 | onInternalClose();
151 | }}
152 | >
153 | {closableObj.closeIcon ?? 'x'}
154 |
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 | {
43 | api.open({
44 | duration: 0.1,
45 | content: (
46 |
47 | {({ name }) => {name}
}
48 |
49 | ),
50 | });
51 | }}
52 | />
53 | {holder}
54 |
55 | );
56 | };
57 |
58 | const { container: demoContainer, unmount } = render( );
59 | fireEvent.click(demoContainer.querySelector('button'));
60 |
61 | expect(document.querySelector('.context-content').textContent).toEqual('bamboo');
62 |
63 | act(() => {
64 | vi.runAllTimers();
65 | });
66 | expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(0);
67 |
68 | unmount();
69 | });
70 |
71 | it('key replace', async () => {
72 | const Demo = () => {
73 | const [api, holder] = useNotification();
74 | return (
75 | <>
76 | {
79 | api.open({
80 | key: 'little',
81 | duration: 1000,
82 | content: light
,
83 | });
84 |
85 | setTimeout(() => {
86 | api.open({
87 | key: 'little',
88 | duration: 1000,
89 | content: bamboo
,
90 | });
91 | }, 500);
92 | }}
93 | />
94 | {holder}
95 | >
96 | );
97 | };
98 |
99 | const { container: demoContainer, unmount } = render( );
100 | fireEvent.click(demoContainer.querySelector('button'));
101 |
102 | expect(document.querySelector('.context-content').textContent).toEqual('light');
103 |
104 | act(() => {
105 | vi.runAllTimers();
106 | });
107 | expect(document.querySelector('.context-content').textContent).toEqual('bamboo');
108 |
109 | unmount();
110 | });
111 |
112 | it('duration config', () => {
113 | const { instance } = renderDemo({
114 | duration: 0,
115 | });
116 |
117 | act(() => {
118 | instance.open({
119 | content:
,
120 | });
121 | });
122 |
123 | expect(document.querySelector('.bamboo')).toBeTruthy();
124 |
125 | act(() => {
126 | vi.runAllTimers();
127 | });
128 | expect(document.querySelector('.bamboo')).toBeTruthy();
129 |
130 | // Can be override
131 | act(() => {
132 | instance.open({
133 | content:
,
134 | duration: 1,
135 | });
136 | });
137 |
138 | act(() => {
139 | vi.runAllTimers();
140 | });
141 | expect(document.querySelector('.little')).toBeFalsy();
142 |
143 | // Can be undefined
144 | act(() => {
145 | instance.open({
146 | content:
,
147 | duration: undefined,
148 | });
149 | });
150 |
151 | act(() => {
152 | vi.runAllTimers();
153 | });
154 | expect(document.querySelector('.light')).toBeTruthy();
155 | });
156 |
157 | it('support renderNotifications', () => {
158 | const Wrapper = ({ children }) => {
159 | return (
160 |
161 | {children}
162 |
163 | );
164 | };
165 |
166 | const renderNotifications = (node: ReactElement) => {
167 | return {node} ;
168 | };
169 | const { instance } = renderDemo({
170 | renderNotifications,
171 | });
172 |
173 | act(() => {
174 | instance.open({
175 | content:
,
176 | style: { color: 'red' },
177 | className: 'custom-notice',
178 | });
179 | });
180 |
181 | expect(document.querySelector('.rc-notification')).toHaveClass('banana');
182 | expect(document.querySelector('.custom-notice')).toHaveClass('apple');
183 | });
184 | });
185 |
--------------------------------------------------------------------------------
/tests/index.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/react';
2 | import React from 'react';
3 | import { act } from 'react-dom/test-utils';
4 | import type { NotificationAPI, NotificationConfig } from '../src';
5 | import { useNotification } from '../src';
6 |
7 | require('../assets/index.less');
8 |
9 | // 🔥 Note: In latest version. We remove static function.
10 | // This only test for hooks usage.
11 | describe('Notification.Basic', () => {
12 | beforeEach(() => {
13 | vi.useFakeTimers();
14 | });
15 |
16 | afterEach(() => {
17 | vi.useRealTimers();
18 | });
19 |
20 | function renderDemo(config?: NotificationConfig) {
21 | let instance: NotificationAPI;
22 |
23 | const Demo = () => {
24 | const [api, holder] = useNotification(config);
25 | instance = api;
26 |
27 | return holder;
28 | };
29 |
30 | const renderResult = render( );
31 |
32 | return { ...renderResult, instance };
33 | }
34 |
35 | it('works', () => {
36 | const { instance, unmount } = renderDemo();
37 |
38 | act(() => {
39 | instance.open({
40 | content: 1
,
41 | duration: 0.1,
42 | });
43 | });
44 | expect(document.querySelector('.test')).toBeTruthy();
45 |
46 | act(() => {
47 | vi.runAllTimers();
48 | });
49 | expect(document.querySelector('.test')).toBeFalsy();
50 |
51 | unmount();
52 | });
53 |
54 | it('works with custom close icon', () => {
55 | const { instance } = renderDemo();
56 |
57 | act(() => {
58 | instance.open({
59 | content: 1
,
60 | closable: {
61 | closeIcon: test-close-icon ,
62 | },
63 | duration: 0,
64 | });
65 | });
66 |
67 | expect(document.querySelectorAll('.test')).toHaveLength(1);
68 | expect(document.querySelector('.test-icon').textContent).toEqual('test-close-icon');
69 | });
70 |
71 | it('works with multi instance', () => {
72 | const { instance } = renderDemo();
73 |
74 | act(() => {
75 | instance.open({
76 | content: 1
,
77 | duration: 0.1,
78 | });
79 | });
80 | act(() => {
81 | instance.open({
82 | content: 2
,
83 | duration: 0.1,
84 | });
85 | });
86 |
87 | expect(document.querySelectorAll('.test')).toHaveLength(2);
88 |
89 | act(() => {
90 | vi.runAllTimers();
91 | });
92 | expect(document.querySelectorAll('.test')).toHaveLength(0);
93 | });
94 |
95 | it('destroy works', () => {
96 | const { instance } = renderDemo();
97 |
98 | act(() => {
99 | instance.open({
100 | content: (
101 |
102 | 222222
103 |
104 | ),
105 | duration: 0.1,
106 | });
107 | });
108 | expect(document.querySelector('.test')).toBeTruthy();
109 |
110 | act(() => {
111 | instance.destroy();
112 | });
113 | expect(document.querySelector('.test')).toBeFalsy();
114 | });
115 |
116 | it('getContainer works', () => {
117 | const id = 'get-container-test';
118 | const div = document.createElement('div');
119 | div.id = id;
120 | div.innerHTML = 'test ';
121 | document.body.appendChild(div);
122 |
123 | const { instance } = renderDemo({
124 | getContainer: () => document.getElementById('get-container-test'),
125 | });
126 |
127 | act(() => {
128 | instance.open({
129 | content: (
130 |
131 | 222222
132 |
133 | ),
134 | duration: 1,
135 | });
136 | });
137 | expect(document.getElementById(id).children).toHaveLength(2);
138 |
139 | act(() => {
140 | instance.destroy();
141 | });
142 | expect(document.getElementById(id).children).toHaveLength(1);
143 |
144 | document.body.removeChild(div);
145 | });
146 |
147 | it('remove notify works', () => {
148 | const { instance, unmount } = renderDemo();
149 |
150 | const key = Math.random();
151 | const close = (k: React.Key) => {
152 | instance.close(k);
153 | };
154 |
155 | act(() => {
156 | instance.open({
157 | content: (
158 |
159 | close(key)}>
160 | close
161 |
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 |
488 | close
489 |
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 | {
747 | api.open({
748 | key: 'little',
749 | duration: 1,
750 | content: light
,
751 | });
752 |
753 | setTimeout(() => {
754 | api.open({
755 | key: 'little',
756 | duration: 1,
757 | content: bamboo
,
758 | onClose,
759 | });
760 | }, 1100);
761 | }}
762 | />
763 | {holder}
764 | >
765 | );
766 | };
767 | const { container: demoContainer, unmount } = render( );
768 | fireEvent.click(demoContainer.querySelector('button'));
769 | act(() => {
770 | vi.runAllTimers();
771 | });
772 | expect(onClose).not.toHaveBeenCalled();
773 | act(() => {
774 | vi.runAllTimers();
775 | });
776 | expect(onClose).toHaveBeenCalled();
777 |
778 | unmount();
779 | });
780 |
781 | it('closes via keyboard Enter key', () => {
782 | const { instance } = renderDemo();
783 | let closeCount = 0;
784 |
785 | act(() => {
786 | instance.open({
787 | content: 1
,
788 | closable: true,
789 | onClose: () => {
790 | closeCount += 1;
791 | },
792 | });
793 | });
794 |
795 | fireEvent.keyDown(document.querySelector('.rc-notification-notice-close'), { key: 'Enter' }); // origin latest
796 | expect(closeCount).toEqual(1);
797 | });
798 |
799 | it('Support aria-* in closable', () => {
800 | const { instance } = renderDemo({
801 | closable: {
802 | closeIcon: 'CloseBtn',
803 | 'aria-label': 'close',
804 | 'aria-labelledby': 'close',
805 | },
806 | });
807 |
808 | act(() => {
809 | instance.open({
810 | content: 1
,
811 | duration: 0,
812 | });
813 | });
814 |
815 | expect(document.querySelector('.rc-notification-notice-close').textContent).toEqual('CloseBtn');
816 | expect(
817 | document.querySelector('.rc-notification-notice-close').getAttribute('aria-label'),
818 | ).toEqual('close');
819 | expect(
820 | document.querySelector('.rc-notification-notice-close').getAttribute('aria-labelledby'),
821 | ).toEqual('close');
822 | });
823 |
824 | describe('showProgress', () => {
825 | it('show with progress', () => {
826 | const { instance } = renderDemo({
827 | duration: 1,
828 | showProgress: true,
829 | });
830 |
831 | act(() => {
832 | instance.open({
833 | content: 1
,
834 | });
835 | });
836 |
837 | expect(document.querySelector('.rc-notification-notice-progress')).toBeTruthy();
838 |
839 | act(() => {
840 | vi.advanceTimersByTime(500);
841 | });
842 |
843 | expect(document.querySelector('.rc-notification-notice-progress')).toBeTruthy();
844 |
845 | act(() => {
846 | vi.advanceTimersByTime(500);
847 | });
848 |
849 | expect(document.querySelector('.rc-notification-notice-progress')).toBeFalsy();
850 | });
851 | });
852 |
853 | describe('Modifying properties through useState can take effect', () => {
854 | it('should show notification and disappear after 5 seconds', async () => {
855 | const Demo: React.FC = () => {
856 | const [duration, setDuration] = React.useState(0);
857 | const [api, holder] = useNotification({ duration });
858 |
859 | return (
860 | <>
861 | setDuration(5)}>
862 | change duration
863 |
864 | {
867 | api.open({
868 | content: `Test Notification`,
869 | });
870 | }}
871 | >
872 | show notification
873 |
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 | {
904 | api.open({
905 | content: `Test Notification`,
906 | closable: { 'aria-label': 'xxx' },
907 | });
908 | }}
909 | >
910 | show notification
911 |
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 | {
18 | api.open({
19 | content: Test
,
20 | duration: null,
21 | });
22 | }}
23 | />
24 | {holder}
25 | >
26 | );
27 | };
28 |
29 | const { container } = render( );
30 | for (let i = 0; i < 3; i++) {
31 | fireEvent.click(container.querySelector('button'));
32 | }
33 | expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(3);
34 | expect(document.querySelector('.rc-notification-stack')).toBeTruthy();
35 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy();
36 |
37 | for (let i = 0; i < 2; i++) {
38 | fireEvent.click(container.querySelector('button'));
39 | }
40 | expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5);
41 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy();
42 |
43 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice'));
44 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy();
45 | });
46 |
47 | it('should collapse when amount is less than threshold', () => {
48 | const Demo = () => {
49 | const [api, holder] = useNotification({
50 | stack: { threshold: 3 },
51 | });
52 | return (
53 | <>
54 | {
57 | api.open({
58 | content: Test
,
59 | duration: null,
60 | closable: true,
61 | });
62 | }}
63 | />
64 | {holder}
65 | >
66 | );
67 | };
68 |
69 | const { container } = render( );
70 | for (let i = 0; i < 5; i++) {
71 | fireEvent.click(container.querySelector('button'));
72 | }
73 | expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(5);
74 | expect(document.querySelector('.rc-notification-stack')).toBeTruthy();
75 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy();
76 |
77 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice'));
78 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy();
79 |
80 | fireEvent.click(document.querySelector('.rc-notification-notice-close'));
81 | expect(document.querySelectorAll('.rc-notification-notice')).toHaveLength(4);
82 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeTruthy();
83 |
84 | // mouseleave will not triggerred if notice is closed
85 | fireEvent.mouseEnter(document.querySelector('.rc-notification-notice-wrapper'));
86 | fireEvent.mouseLeave(document.querySelector('.rc-notification-notice-wrapper'));
87 | expect(document.querySelector('.rc-notification-stack-expanded')).toBeFalsy();
88 | });
89 | });
90 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "moduleResolution": "node",
5 | "baseUrl": "./",
6 | "jsx": "react",
7 | "declaration": true,
8 | "skipLibCheck": true,
9 | "esModuleInterop": true,
10 | "paths": {
11 | "@/*": ["src/*"],
12 | "@@/*": [".dumi/tmp/*"],
13 | "rc-notification": ["src/index.tsx"]
14 | },
15 | "types": ["vitest/globals"]
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/type.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css';
2 |
3 | declare module '*.less';
--------------------------------------------------------------------------------
/vitest-setup.ts:
--------------------------------------------------------------------------------
1 | import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
2 | import * as matchers from '@testing-library/jest-dom/matchers';
3 | import { expect } from 'vitest';
4 |
5 | declare module 'vitest' {
6 | interface Assertion extends jest.Matchers, TestingLibraryMatchers {}
7 | }
8 |
9 | expect.extend(matchers);
10 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | include: ['**/tests/*.test.*'],
6 | globals: true,
7 | setupFiles: './vitest-setup.ts',
8 | environment: 'jsdom',
9 | },
10 | });
11 |
--------------------------------------------------------------------------------