├── bunfig.toml ├── type.d.ts ├── .husky └── pre-commit ├── docs ├── demo │ ├── hooks.md │ ├── context.md │ ├── stack.md │ ├── maxCount.md │ └── showProgress.md ├── index.md └── examples │ ├── motion.ts │ ├── maxCount.tsx │ ├── showProgress.tsx │ ├── context.tsx │ ├── stack.tsx │ └── hooks.tsx ├── .fatherrc.ts ├── .prettierrc ├── jest.config.js ├── .github ├── workflows │ ├── main.yml │ └── codeql.yml └── dependabot.yml ├── now.json ├── vitest.config.ts ├── .editorconfig ├── src ├── index.ts ├── NotificationProvider.tsx ├── hooks │ ├── useStack.ts │ └── useNotification.tsx ├── interface.ts ├── Notice.tsx ├── Notifications.tsx └── NoticeList.tsx ├── vitest-setup.ts ├── tsconfig.json ├── .dumirc.ts ├── .gitignore ├── .eslintrc.js ├── CHANGELOG.md ├── LICENSE.md ├── package.json ├── tests ├── stack.test.tsx ├── hooks.test.tsx └── index.test.tsx ├── assets └── index.less └── README.md /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /type.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | 3 | declare module '*.less'; -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /docs/demo/hooks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: hooks 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/context.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: context 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/demo/stack.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: stack 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /docs/demo/maxCount.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: maxCount 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-notification 4 | description: notification ui component for react 5 | --- 6 | 7 | -------------------------------------------------------------------------------- /docs/demo/showProgress.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: showProgress 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: ['/tests/setup.js'], 3 | snapshotSerializers: [require.resolve("enzyme-to-json/serializer")] 4 | }; 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | pnpm-lock.yaml 34 | 35 | # umi 36 | .umi 37 | .umi-production 38 | .umi-test 39 | .env.local 40 | 41 | # dumi 42 | .dumi/tmp 43 | .dumi/tmp-production 44 | 45 | bun.lockb 46 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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, closable: true }); 19 | 20 | return ( 21 | 22 | 30 | 38 | {holder} 39 | 40 | ); 41 | }; 42 | 43 | export default Demo; 44 | -------------------------------------------------------------------------------- /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 | false | null; 10 | showProgress?: boolean; 11 | pauseOnHover?: boolean; 12 | 13 | closable?: 14 | | boolean 15 | | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); 16 | className?: string; 17 | style?: React.CSSProperties; 18 | classNames?: { 19 | [key in NoticeSemanticProps]?: string; 20 | }; 21 | styles?: { 22 | [key in NoticeSemanticProps]?: React.CSSProperties; 23 | }; 24 | /** @private Internal usage. Do not override in your code */ 25 | props?: React.HTMLAttributes & Record; 26 | 27 | onClose?: VoidFunction; 28 | onClick?: React.MouseEventHandler; 29 | } 30 | 31 | export interface OpenConfig extends NoticeConfig { 32 | key: React.Key; 33 | placement?: Placement; 34 | content?: React.ReactNode; 35 | duration?: number | false | null; 36 | } 37 | 38 | export type InnerOpenConfig = OpenConfig & { times?: number }; 39 | 40 | export type Placements = Partial>; 41 | 42 | export type StackConfig = 43 | | boolean 44 | | { 45 | /** 46 | * When number is greater than threshold, notifications will be stacked together. 47 | * @default 3 48 | */ 49 | threshold?: number; 50 | /** 51 | * Offset when notifications are stacked together. 52 | * @default 8 53 | */ 54 | offset?: number; 55 | /** 56 | * Spacing between each notification when expanded. 57 | */ 58 | gap?: number; 59 | }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/notification", 3 | "version": "1.2.0", 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 | "dependencies": { 51 | "@rc-component/motion": "^1.1.4", 52 | "@rc-component/util": "^1.2.1", 53 | "clsx": "^2.1.1" 54 | }, 55 | "devDependencies": { 56 | "@rc-component/father-plugin": "^2.0.4", 57 | "@rc-component/np": "^1.0.3", 58 | "@testing-library/jest-dom": "^6.0.0", 59 | "@testing-library/react": "^15.0.7", 60 | "@types/node": "^24.5.2", 61 | "@types/react": "^18.0.0", 62 | "@types/react-dom": "^18.0.0", 63 | "@types/testing-library__jest-dom": "^6.0.0", 64 | "@typescript-eslint/eslint-plugin": "^5.59.7", 65 | "@typescript-eslint/parser": "^5.59.7", 66 | "@umijs/fabric": "^2.0.0", 67 | "@vitest/coverage-v8": "^0.34.2", 68 | "dumi": "^2.1.0", 69 | "eslint": "^7.8.1", 70 | "father": "^4.0.0", 71 | "gh-pages": "^3.1.0", 72 | "husky": "^8.0.3", 73 | "jsdom": "^24.0.0", 74 | "less": "^4.2.0", 75 | "lint-staged": "^14.0.1", 76 | "prettier": "^3.0.2", 77 | "react": "^18.0.0", 78 | "react-dom": "^18.0.0", 79 | "typescript": "^5.4.5", 80 | "vitest": "^0.34.2" 81 | }, 82 | "peerDependencies": { 83 | "react": ">=16.9.0", 84 | "react-dom": ">=16.9.0" 85 | }, 86 | "lint-staged": { 87 | "**/*.{js,jsx,tsx,ts,md,json}": [ 88 | "prettier --write", 89 | "git add" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 154 | )} 155 | 156 | {/* Progress Bar */} 157 | {mergedShowProgress && ( 158 | 159 | {validPercent + '%'} 160 | 161 | )} 162 | 163 | ); 164 | }); 165 | 166 | export default Notify; 167 | -------------------------------------------------------------------------------- /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 | const closable = config?.closable; 49 | const closableObj = closable && typeof closable === 'object' ? closable : {}; 50 | const { onClose: closableOnClose } = closableObj; 51 | closableOnClose?.(); 52 | config?.onClose?.(); 53 | setConfigList((list) => list.filter((item) => item.key !== key)); 54 | }; 55 | 56 | // ========================= Refs ========================= 57 | React.useImperativeHandle(ref, () => ({ 58 | open: (config) => { 59 | setConfigList((list) => { 60 | let clone = [...list]; 61 | 62 | // Replace if exist 63 | const index = clone.findIndex((item) => item.key === config.key); 64 | const innerConfig: InnerOpenConfig = { ...config }; 65 | if (index >= 0) { 66 | innerConfig.times = ((list[index] as InnerOpenConfig)?.times || 0) + 1; 67 | clone[index] = innerConfig; 68 | } else { 69 | innerConfig.times = 0; 70 | clone.push(innerConfig); 71 | } 72 | 73 | if (maxCount > 0 && clone.length > maxCount) { 74 | clone = clone.slice(-maxCount); 75 | } 76 | 77 | return clone; 78 | }); 79 | }, 80 | close: (key) => { 81 | onNoticeClose(key); 82 | }, 83 | destroy: () => { 84 | setConfigList([]); 85 | }, 86 | })); 87 | 88 | // ====================== Placements ====================== 89 | const [placements, setPlacements] = React.useState({}); 90 | 91 | React.useEffect(() => { 92 | const nextPlacements: Placements = {}; 93 | 94 | configList.forEach((config) => { 95 | const { placement = 'topRight' } = config; 96 | 97 | if (placement) { 98 | nextPlacements[placement] = nextPlacements[placement] || []; 99 | nextPlacements[placement].push(config); 100 | } 101 | }); 102 | 103 | // Fill exist placements to avoid empty list causing remove without motion 104 | Object.keys(placements).forEach((placement) => { 105 | nextPlacements[placement] = nextPlacements[placement] || []; 106 | }); 107 | 108 | setPlacements(nextPlacements); 109 | }, [configList]); 110 | 111 | // Clean up container if all notices fade out 112 | const onAllNoticeRemoved = (placement: Placement) => { 113 | setPlacements((originPlacements) => { 114 | const clone = { 115 | ...originPlacements, 116 | }; 117 | const list = clone[placement] || []; 118 | 119 | if (!list.length) { 120 | delete clone[placement]; 121 | } 122 | 123 | return clone; 124 | }); 125 | }; 126 | 127 | // Effect tell that placements is empty now 128 | const emptyRef = React.useRef(false); 129 | React.useEffect(() => { 130 | if (Object.keys(placements).length > 0) { 131 | emptyRef.current = true; 132 | } else if (emptyRef.current) { 133 | // Trigger only when from exist to empty 134 | onAllRemoved?.(); 135 | emptyRef.current = false; 136 | } 137 | }, [placements]); 138 | // ======================== Render ======================== 139 | if (!container) { 140 | return null; 141 | } 142 | 143 | const placementList = Object.keys(placements) as Placement[]; 144 | 145 | return createPortal( 146 | <> 147 | {placementList.map((placement) => { 148 | const placementConfigList = placements[placement]; 149 | 150 | const list = ( 151 | 163 | ); 164 | 165 | return renderNotifications 166 | ? renderNotifications(list, { prefixCls, key: placement }) 167 | : list; 168 | })} 169 | , 170 | container, 171 | ); 172 | }); 173 | 174 | if (process.env.NODE_ENV !== 'production') { 175 | Notifications.displayName = 'Notifications'; 176 | } 177 | 178 | export default Notifications; 179 | -------------------------------------------------------------------------------- /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?: 19 | | boolean 20 | | ({ closeIcon?: React.ReactNode; onClose?: VoidFunction } & React.AriaAttributes); 21 | maxCount?: number; 22 | duration?: number | false | null; 23 | showProgress?: boolean; 24 | pauseOnHover?: boolean; 25 | /** @private. Config for notification holder style. Safe to remove if refactor */ 26 | className?: (placement: Placement) => string; 27 | /** @private. Config for notification holder style. Safe to remove if refactor */ 28 | style?: (placement: Placement) => React.CSSProperties; 29 | /** @private Trigger when all the notification closed. */ 30 | onAllRemoved?: VoidFunction; 31 | stack?: StackConfig; 32 | /** @private Slot for style in Notifications */ 33 | renderNotifications?: NotificationsProps['renderNotifications']; 34 | } 35 | 36 | export interface NotificationAPI { 37 | open: (config: OptionalConfig) => void; 38 | close: (key: React.Key) => void; 39 | destroy: () => void; 40 | } 41 | 42 | interface OpenTask { 43 | type: 'open'; 44 | config: OpenConfig; 45 | } 46 | 47 | interface CloseTask { 48 | type: 'close'; 49 | key: React.Key; 50 | } 51 | 52 | interface DestroyTask { 53 | type: 'destroy'; 54 | } 55 | 56 | type Task = OpenTask | CloseTask | DestroyTask; 57 | 58 | let uniqueKey = 0; 59 | 60 | function mergeConfig(...objList: Partial[]): T { 61 | const clone: T = {} as T; 62 | 63 | objList.forEach((obj) => { 64 | if (obj) { 65 | Object.keys(obj).forEach((key) => { 66 | const val = obj[key]; 67 | 68 | if (val !== undefined) { 69 | clone[key] = val; 70 | } 71 | }); 72 | } 73 | }); 74 | 75 | return clone; 76 | } 77 | 78 | export default function useNotification( 79 | rootConfig: NotificationConfig = {}, 80 | ): [NotificationAPI, React.ReactElement] { 81 | const { 82 | getContainer = defaultGetContainer, 83 | motion, 84 | prefixCls, 85 | maxCount, 86 | className, 87 | style, 88 | onAllRemoved, 89 | stack, 90 | renderNotifications, 91 | ...shareConfig 92 | } = rootConfig; 93 | 94 | const [container, setContainer] = React.useState(); 95 | const notificationsRef = React.useRef(); 96 | const contextHolder = ( 97 | 109 | ); 110 | 111 | const [taskQueue, setTaskQueue] = React.useState([]); 112 | 113 | const open = useEvent((config) => { 114 | const mergedConfig = mergeConfig(shareConfig, config); 115 | if (mergedConfig.key === null || mergedConfig.key === undefined) { 116 | mergedConfig.key = `rc-notification-${uniqueKey}`; 117 | uniqueKey += 1; 118 | } 119 | 120 | setTaskQueue((queue) => [...queue, { type: 'open', config: mergedConfig }]); 121 | }); 122 | 123 | // ========================= Refs ========================= 124 | const api = React.useMemo( 125 | () => ({ 126 | open: open, 127 | close: (key) => { 128 | setTaskQueue((queue) => [...queue, { type: 'close', key }]); 129 | }, 130 | destroy: () => { 131 | setTaskQueue((queue) => [...queue, { type: 'destroy' }]); 132 | }, 133 | }), 134 | [], 135 | ); 136 | 137 | // ======================= Container ====================== 138 | // React 18 should all in effect that we will check container in each render 139 | // Which means getContainer should be stable. 140 | React.useEffect(() => { 141 | setContainer(getContainer()); 142 | }); 143 | 144 | // ======================== Effect ======================== 145 | React.useEffect(() => { 146 | // Flush task when node ready 147 | if (notificationsRef.current && taskQueue.length) { 148 | taskQueue.forEach((task) => { 149 | switch (task.type) { 150 | case 'open': 151 | notificationsRef.current.open(task.config); 152 | break; 153 | 154 | case 'close': 155 | notificationsRef.current.close(task.key); 156 | break; 157 | 158 | case 'destroy': 159 | notificationsRef.current.destroy(); 160 | break; 161 | } 162 | }); 163 | 164 | // https://github.com/ant-design/ant-design/issues/52590 165 | // React `startTransition` will run once `useEffect` but many times `setState`, 166 | // So `setTaskQueue` with filtered array will cause infinite loop. 167 | // We cache the first match queue instead. 168 | let oriTaskQueue: Task[]; 169 | let tgtTaskQueue: Task[]; 170 | 171 | // React 17 will mix order of effect & setState in async 172 | // - open: setState[0] 173 | // - effect[0] 174 | // - open: setState[1] 175 | // - effect setState([]) * here will clean up [0, 1] in React 17 176 | setTaskQueue((oriQueue) => { 177 | if (oriTaskQueue !== oriQueue || !tgtTaskQueue) { 178 | oriTaskQueue = oriQueue; 179 | tgtTaskQueue = oriQueue.filter((task) => !taskQueue.includes(task)); 180 | } 181 | 182 | return tgtTaskQueue; 183 | }); 184 | } 185 | }, [taskQueue]); 186 | 187 | // ======================== Return ======================== 188 | return [api, contextHolder]; 189 | } 190 | -------------------------------------------------------------------------------- /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 | &::before { 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 | -------------------------------------------------------------------------------- /src/NoticeList.tsx: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, FC } from 'react'; 2 | import React, { useContext, useEffect, useRef, useState } from 'react'; 3 | import { clsx } from 'clsx'; 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 | -------------------------------------------------------------------------------- /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
closableBoolean | { closeIcon: ReactNode, onClose: VoidFunction }whether show close button
onCloseFunctioncalled when notice close
durationnumber | false4.5The delay for automatic closing in seconds. Set to 0 or false to not close automatically.
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 | -------------------------------------------------------------------------------- /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 | 162 |

163 | ), 164 | key, 165 | duration: false, 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: false, 196 | }); 197 | }); 198 | 199 | act(() => { 200 | instance.open({ 201 | content: ( 202 |

203 | {value} 204 |

205 | ), 206 | key, 207 | duration: false, 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: false, 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 | 962 | 972 | {holder} 973 | 974 | ); 975 | }; 976 | 977 | const { getByTestId } = render(); 978 | 979 | fireEvent.click(getByTestId('show-notification')); 980 | 981 | expect(document.querySelectorAll('.rc-notification-notice').length).toBe(1); 982 | fireEvent.click(getByTestId('change-duration')); 983 | fireEvent.click(getByTestId('show-notification')); 984 | expect(document.querySelectorAll('.rc-notification-notice').length).toBe(2); 985 | 986 | act(() => { 987 | vi.advanceTimersByTime(5000); 988 | }); 989 | 990 | expect(document.querySelectorAll('.rc-notification-notice').length).toBe(1); 991 | }); 992 | }); 993 | it('notification close node ', () => { 994 | const Demo = () => { 995 | const [duration] = React.useState(0); 996 | const [api, holder] = useNotification({ duration }); 997 | return ( 998 | <> 999 | 1010 | {holder} 1011 | 1012 | ); 1013 | }; 1014 | const { getByTestId } = render(); 1015 | fireEvent.click(getByTestId('show-notification')); 1016 | expect(document.querySelector('button.rc-notification-notice-close')).toHaveAttribute( 1017 | 'aria-label', 1018 | 'xxx', 1019 | ); 1020 | }); 1021 | }); 1022 | --------------------------------------------------------------------------------