├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE.md
├── PULL_REQUEST_TEMPLATE.md
└── workflows
│ └── build.yaml
├── .gitignore
├── .nycrc.json
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── cypress.config.ts
├── cypress
└── support
│ ├── commands.ts
│ ├── component-index.html
│ ├── component.ts
│ └── style.css
├── lefthook.yml
├── package.json
├── playground
├── .eslintrc.cjs
├── .gitignore
├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
│ └── vite.svg
├── src
│ ├── assets
│ │ └── react.svg
│ ├── components
│ │ ├── App.tsx
│ │ ├── Checkbox.tsx
│ │ ├── ContainerCode.tsx
│ │ ├── Header.tsx
│ │ ├── Radio.tsx
│ │ ├── ToastCode.tsx
│ │ └── constants.ts
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
├── pnpm-lock.yaml
├── src
├── addons
│ └── use-notification-center
│ │ ├── NotificationCenter.cy.tsx
│ │ ├── index.ts
│ │ └── useNotificationCenter.ts
├── components
│ ├── CloseButton.cy.tsx
│ ├── CloseButton.tsx
│ ├── Icons.cy.tsx
│ ├── Icons.tsx
│ ├── ProgressBar.cy.tsx
│ ├── ProgressBar.tsx
│ ├── Toast.cy.tsx
│ ├── Toast.tsx
│ ├── ToastContainer.tsx
│ ├── Transitions.tsx
│ └── index.tsx
├── core
│ ├── containerObserver.ts
│ ├── genToastId.ts
│ ├── index.ts
│ ├── store.ts
│ ├── toast.cy.tsx
│ └── toast.ts
├── hooks
│ ├── index.ts
│ ├── useIsomorphicLayoutEffect.ts
│ ├── useToast.ts
│ └── useToastContainer.ts
├── index.ts
├── style.css
├── tests.cy.tsx
├── types.ts
└── utils
│ ├── collapseToast.ts
│ ├── constant.ts
│ ├── cssTransition.tsx
│ ├── index.ts
│ ├── mapper.ts
│ └── propValidator.ts
├── tsconfig.json
├── tsup.config.ts
└── vite.config.mts
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: fkhadra
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | **Do you want to request a _feature_ or report a _bug_?**
2 |
3 | **What is the current behavior?**
4 |
5 | **If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn't have dependencies other than React. Paste the link to your [Stackblitz](https://stackblitz.com/edit/react-toastify-getting-started) example below:**
6 |
7 | **What is the expected behavior?**
8 |
9 | **Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?**
10 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | **Before submitting a pull request,** please make sure the following is done:
2 |
3 | 1. Fork [the repository](https://github.com/fkhadra/react-toastify) and create your branch from `main`.
4 | 2. Run `pnpm i` in the repository root.
5 | 3. If you've fixed a bug or added code that should be tested, add tests!
6 | 4. Ensure the test suite passes (`pnpm test`).
7 | 5. Run `pnpm start` to test your changes in the playground.
8 | 6. Update the readme is needed
9 | 7. Update the typescript definition is needed
10 | 8. Format your code with [prettier](https://github.com/prettier/prettier) (`pnpm prettier`).
11 | 9. Make sure your code lints (`pnpm lint:fix`).
12 |
13 | For new features, please make sure that there is an issue related to it.
14 |
15 | **Learn more about contributing [here](https://github.com/fkhadra/react-toastify/blob/master/CONTRIBUTING.md)**
16 |
--------------------------------------------------------------------------------
/.github/workflows/build.yaml:
--------------------------------------------------------------------------------
1 | name: React-toastify CI
2 |
3 | on: [pull_request, push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v4.2.2
11 | - name: Install node
12 | uses: actions/setup-node@v4.1.0
13 | with:
14 | node-version: '22.x'
15 | - name: Install pnpm
16 | uses: pnpm/action-setup@v4
17 | with:
18 | version: 9
19 | - name: Install dependencies
20 | run: pnpm i
21 | # - name: Lint
22 | # run: yarn lint
23 | - name: Setup
24 | run: pnpm run setup
25 | - name: Build
26 | run: pnpm build
27 | - name: Test
28 | run: pnpm run test:run
29 | - uses: actions/upload-artifact@v3
30 | if: failure()
31 | with:
32 | name: cypress-screenshots
33 | path: cypress/screenshots
34 | - uses: actions/upload-artifact@v3
35 | if: always()
36 | with:
37 | name: cypress-videos
38 | path: cypress/videos
39 | - name: Coveralls GitHub Action
40 | uses: coverallsapp/github-action@v2.3.4
41 | with:
42 | github-token: ${{ secrets.GITHUB_TOKEN }}
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | node_modules/
3 | lib/
4 | .sass-cache/
5 | npm-debug.log
6 | coverage/
7 | yarn-error.log
8 | .DS_STORE
9 | cjs/
10 | esm/
11 | dist/
12 | .cache
13 | /addons
14 | .nyc_output
15 | cypress/videos/*
16 | cypress/screenshots/*
17 | .husky
--------------------------------------------------------------------------------
/.nycrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "all": true,
3 | "extends": "@istanbuljs/nyc-config-typescript",
4 | "check-coverage": true,
5 | "include": [
6 | "src/**/*.ts",
7 | "src/**/*.tsx"
8 | ],
9 | "exclude": [
10 | "cypress/**/*.*",
11 | "src/types.ts",
12 | "**/*.d.ts",
13 | "**/*.cy.tsx",
14 | "**/*.cy.ts"
15 | ]
16 | }
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at fdkhadra@gmail.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1:
4 |
5 | When contributing to this repository, please first discuss the change you wish to make via issue before making a change.
6 |
7 | Please note we have a code of conduct, please follow it in all your interactions with the project.
8 |
9 | ## General Guidelines
10 |
11 | - Before starting to work on something, please open an issue first
12 | - If adding a new feature, write the corresponding test
13 | - Ensure that nothing get broke. You can use the playground for that
14 | - If applicable, update the [documentation](https://github.com/fkhadra/react-toastify-doc)
15 | - Use prettier before committing 😭
16 | - When solving a bug, please provide the steps to reproduce it(codesandbox or stackblitz are our best friends for that)
17 | - Tchill 👌
18 |
19 | ## Setup
20 |
21 | ### Pre-requisites
22 |
23 | - *Node:* `^18.0.0`
24 | - *Yarn*
25 |
26 | ### Install
27 |
28 | Clone the repository and create a local branch:
29 |
30 | ```sh
31 | git clone https://github.com/fkhadra/react-toastify.git
32 | cd react-toastify
33 |
34 | git checkout -b my-branch
35 | ```
36 |
37 | Install dependencies:
38 |
39 | ```sh
40 | pnpm install
41 | // then
42 | pnpm setup
43 | ```
44 |
45 | ## Developing
46 |
47 | ```sh
48 | # launch the playground
49 | pnpm start
50 |
51 | # Run tests 💩
52 | pnpm test
53 |
54 | # Prettify all the things
55 | pnpm prettier
56 | ```
57 |
58 | ### Playground dir
59 |
60 | The playground let you test your changes, it's like the demo of react-toastify. Most of the time you don't need to modify it unless you add new features.
61 |
62 | ### Src
63 |
64 | - [toast:](https://github.com/fkhadra/react-toastify/blob/main/src/core/toast.ts) Contain the exposed api (`toast.success...`).
65 |
66 | ## License
67 | By contributing, you agree that your contributions will be licensed under its [MIT License](https://github.com/fkhadra/react-toastify/blob/main/LICENSE).
68 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Fadi Khadra
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React-Toastify
2 |
3 | [](https://opencollective.com/react-toastify) 
4 | 
5 | 
6 | 
7 | 
8 |
9 |
10 | 
11 |
12 | 
13 |
14 | 
15 |
16 | 🎉 React-Toastify allows you to add notifications to your app with ease.
17 |
18 | ## Installation
19 |
20 | ```
21 | $ npm install --save react-toastify
22 | $ yarn add react-toastify
23 | ```
24 |
25 | ```jsx
26 | import React from 'react';
27 |
28 | import { ToastContainer, toast } from 'react-toastify';
29 |
30 | function App(){
31 | const notify = () => toast("Wow so easy!");
32 |
33 | return (
34 |
35 | Notify!
36 |
37 |
38 | );
39 | }
40 | ```
41 |
42 | ## Documentation
43 |
44 | Check the [documentation](https://fkhadra.github.io/react-toastify/introduction) to get you started!
45 |
46 | ## Features
47 |
48 | - Easy to set up for real, you can make it work in less than 10sec!
49 | - Super easy to customize
50 | - RTL support
51 | - Swipe to close 👌
52 | - Can choose swipe direction
53 | - Super easy to use an animation of your choice. Works well with animate.css for example
54 | - Can display a react component inside the toast!
55 | - Has ```onOpen``` and ```onClose``` hooks. Both can access the props passed to the react component rendered inside the toast
56 | - Can remove a toast programmatically
57 | - Define behavior per toast
58 | - Pause toast when the window loses focus 👁
59 | - Fancy progress bar to display the remaining time
60 | - Possibility to update a toast
61 | - You can control the progress bar a la `nprogress` 😲
62 | - You can limit the number of toast displayed at the same time
63 | - Dark mode 🌒
64 | - Pause timer programmaticaly
65 | - Stacked notifications!
66 | - And much more !
67 |
68 | ## Demo
69 |
70 | [A demo is worth a thousand words](https://fkhadra.github.io/react-toastify/introduction)
71 |
72 |
73 | ## Contribute
74 |
75 | Show your ❤️ and support by giving a ⭐. Any suggestions are welcome! Take a look at the contributing guide.
76 |
77 | You can also find me on [reactiflux](https://www.reactiflux.com/). My pseudo is Fadi.
78 |
79 | ## Contributors
80 |
81 | ### Code Contributors
82 |
83 | This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)].
84 |
85 |
86 | ### Financial Contributors
87 |
88 | Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/react-toastify/contribute)]
89 |
90 | #### Individuals
91 |
92 |
93 |
94 | #### Organizations
95 |
96 | Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/react-toastify/contribute)]
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | ## Release Notes
110 |
111 | You can find the release note for the latest release [here](https://github.com/fkhadra/react-toastify/releases/latest)
112 |
113 | You can browse them all [here](https://github.com/fkhadra/react-toastify/releases)
114 |
115 | ## License
116 |
117 | Licensed under MIT
118 |
--------------------------------------------------------------------------------
/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress';
2 |
3 | export default defineConfig({
4 | component: {
5 | setupNodeEvents(on, config) {
6 | require('@cypress/code-coverage/task')(on, config);
7 | return config;
8 | },
9 | devServer: {
10 | framework: 'react',
11 | bundler: 'vite'
12 | }
13 | }
14 | });
15 |
--------------------------------------------------------------------------------
/cypress/support/commands.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | // ***********************************************
4 | // This example commands.ts shows you how to
5 | // create various custom commands and overwrite
6 | // existing commands.
7 | //
8 | // For more comprehensive examples of custom
9 | // commands please read more here:
10 | // https://on.cypress.io/custom-commands
11 | // ***********************************************
12 | //
13 | //
14 | // -- This is a parent command --
15 | // Cypress.Commands.add('login', (email, password) => { ... })
16 | //
17 | //
18 | // -- This is a child command --
19 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
20 | //
21 | //
22 | // -- This is a dual command --
23 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
24 | //
25 | //
26 | // -- This will overwrite an existing command --
27 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
28 | //
29 | declare global {
30 | namespace Cypress {
31 | interface Chainable {
32 | resolveEntranceAnimation(): void;
33 | }
34 | }
35 | }
36 |
37 | import '@4tw/cypress-drag-drop';
38 | import '@testing-library/cypress/add-commands';
39 |
40 | Cypress.Commands.add('resolveEntranceAnimation', () => {
41 | cy.wait(800);
42 | });
43 |
--------------------------------------------------------------------------------
/cypress/support/component-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Components App
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/cypress/support/component.ts:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/component.ts is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | // cypress/support/e2e.js
18 | import '@cypress/code-coverage/support';
19 | import './commands';
20 | import './style.css';
21 | import '../../src/style.css';
22 |
23 | // Alternatively you can use CommonJS syntax:
24 | // require('./commands')
25 |
26 | import { mount } from 'cypress/react18';
27 |
28 | // Augment the Cypress namespace to include type definitions for
29 | // your custom command.
30 | // Alternatively, can be defined in cypress/support/component.d.ts
31 | // with a at the top of your spec.
32 | declare global {
33 | namespace Cypress {
34 | interface Chainable {
35 | mount: typeof mount;
36 | }
37 | }
38 | }
39 |
40 | Cypress.Commands.add('mount', mount);
41 |
42 | // Example use:
43 | // cy.mount( )
44 |
--------------------------------------------------------------------------------
/cypress/support/style.css:
--------------------------------------------------------------------------------
1 |
2 | [data-cy-root]{
3 | height: 80vh;
4 | }
--------------------------------------------------------------------------------
/lefthook.yml:
--------------------------------------------------------------------------------
1 | pre-commit:
2 | parallel: true
3 | commands:
4 | lint-staged:
5 | glob: "*.{js,ts,jsx,tsx,css}"
6 | run: pnpm lint-staged
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "11.0.5",
3 | "license": "MIT",
4 | "description": "React notification made easy",
5 | "keywords": [
6 | "react",
7 | "notification",
8 | "toast",
9 | "react-component",
10 | "react-toastify",
11 | "push",
12 | "alert",
13 | "snackbar",
14 | "message"
15 | ],
16 | "files": [
17 | "dist",
18 | "addons"
19 | ],
20 | "scripts": {
21 | "prepare": "lefthook install",
22 | "setup": "pnpm link .",
23 | "start": "cd playground && pnpm dev",
24 | "test": "cypress open --component",
25 | "test:run": "cypress run --component -b chrome",
26 | "prettier": "prettier --write src",
27 | "build": "tsup && cp src/style.css dist/ReactToastify.css && rm dist/unstyled.css*"
28 | },
29 | "peerDependencies": {
30 | "react": "^18 || ^19",
31 | "react-dom": "^18 || ^19"
32 | },
33 | "prettier": {
34 | "printWidth": 120,
35 | "semi": true,
36 | "singleQuote": true,
37 | "trailingComma": "none",
38 | "arrowParens": "avoid"
39 | },
40 | "name": "react-toastify",
41 | "repository": {
42 | "type": "git",
43 | "url": "git+https://github.com/fkhadra/react-toastify.git"
44 | },
45 | "author": "Fadi Khadra (https://fkhadra.github.io)",
46 | "bugs": {
47 | "url": "https://github.com/fkhadra/react-toastify/issues"
48 | },
49 | "homepage": "https://github.com/fkhadra/react-toastify#readme",
50 | "devDependencies": {
51 | "@4tw/cypress-drag-drop": "^2.2.5",
52 | "@cypress/code-coverage": "^3.13.9",
53 | "@istanbuljs/nyc-config-typescript": "^1.0.2",
54 | "@testing-library/cypress": "^10.0.2",
55 | "@types/node": "^22.10.2",
56 | "@types/react": "^19.0.1",
57 | "@types/react-dom": "^19.0.2",
58 | "@vitejs/plugin-react": "^4.3.4",
59 | "coveralls": "^3.1.1",
60 | "cypress": "^13.16.1",
61 | "lefthook": "^1.9.2",
62 | "lint-staged": "^15.2.11",
63 | "postcss": "^8.4.49",
64 | "prettier": "3.4.2",
65 | "react": "^19.0.0",
66 | "react-dom": "^19.0.0",
67 | "tsup": "^8.3.5",
68 | "typescript": "^5.7.2",
69 | "vite": "^6.0.3",
70 | "vite-plugin-istanbul": "^6.0.2"
71 | },
72 | "dependencies": {
73 | "clsx": "^2.1.1"
74 | },
75 | "main": "dist/index.js",
76 | "typings": "dist/index.d.ts",
77 | "module": "dist/index.mjs",
78 | "source": "src/index.ts",
79 | "exports": {
80 | ".": {
81 | "types": "./dist/index.d.ts",
82 | "import": "./dist/index.mjs",
83 | "require": "./dist/index.js"
84 | },
85 | "./unstyled": {
86 | "types": "./dist/unstyled.d.ts",
87 | "import": "./dist/unstyled.mjs",
88 | "require": "./dist/unstyled.js"
89 | },
90 | "./dist/ReactToastify.css": "./dist/ReactToastify.css",
91 | "./ReactToastify.css": "./dist/ReactToastify.css",
92 | "./package.json": "./package.json",
93 | "./addons/use-notification-center": {
94 | "types": "./addons/use-notification-center/index.d.ts",
95 | "import": "./addons/use-notification-center/index.mjs",
96 | "require": "./addons/use-notification-center/index.js"
97 | },
98 | "./notification-center": {
99 | "types": "./addons/use-notification-center/index.d.ts",
100 | "import": "./addons/use-notification-center/index.mjs",
101 | "require": "./addons/use-notification-center/index.js"
102 | }
103 | },
104 | "lint-staged": {
105 | "*.{js,jsx,ts,tsx,md,html,css}": "prettier --write"
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/playground/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true },
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | 'plugin:react-hooks/recommended',
7 | ],
8 | parser: '@typescript-eslint/parser',
9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
10 | plugins: ['react-refresh'],
11 | rules: {
12 | 'react-refresh/only-export-components': 'warn',
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/playground/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/playground/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/playground/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "playground",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "react": "^19.0.0",
14 | "react-dom": "^19.0.0"
15 | },
16 | "devDependencies": {
17 | "@types/react": "^19.0.0",
18 | "@types/react-dom": "^19.0.0",
19 | "@vitejs/plugin-react": "^4.3.4",
20 | "typescript": "^5.7.2",
21 | "vite": "^6.0.1"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/playground/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/playground/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/playground/src/components/App.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * The playground could use some love 💖. To the brave soul reading this
3 | * message, any help would be appreciated 🙏
4 | *
5 | * The code is full of bad assertion 😆
6 | */
7 |
8 | import { Checkbox } from './Checkbox';
9 | import { ContainerCode, ContainerCodeProps } from './ContainerCode';
10 | import { Header } from './Header';
11 | import { Radio } from './Radio';
12 | import { ToastCode, ToastCodeProps } from './ToastCode';
13 | import { flags, positions, themes, transitions, typs } from './constants';
14 |
15 | import React from 'react';
16 | import { Id, toast, ToastContainer } from '../../../src';
17 | import { defaultProps } from '../../../src/components/ToastContainer';
18 |
19 | // Attach to window. Can be useful to debug
20 | // @ts-ignore
21 | window.toast = toast;
22 |
23 | class App extends React.Component {
24 | state = App.getDefaultState();
25 | toastId: Id;
26 | resolvePromise = true;
27 |
28 | static getDefaultState() {
29 | return {
30 | ...defaultProps,
31 | transition: 'bounce',
32 | type: 'default',
33 | progress: '',
34 | disableAutoClose: false,
35 | limit: 0,
36 | theme: 'light'
37 | };
38 | }
39 |
40 | handleReset = () =>
41 | this.setState({
42 | ...App.getDefaultState()
43 | });
44 |
45 | clearAll = () => toast.dismiss();
46 |
47 | showToast = () => {
48 | this.toastId =
49 | this.state.type === 'default'
50 | ? toast('🦄 Wow so easy !', { progress: this.state.progress })
51 | : toast[this.state.type]('🚀 Wow so easy !', {
52 | progress: this.state.progress
53 | });
54 | };
55 |
56 | firePromise = () => {
57 | toast.promise(
58 | new Promise((resolve, reject) => {
59 | setTimeout(() => {
60 | this.resolvePromise ? resolve(null) : reject(null);
61 | this.resolvePromise = !this.resolvePromise;
62 | }, 3000);
63 | }),
64 | {
65 | pending: 'Promise is pending',
66 | success: 'Promise resolved 👌',
67 | error: 'Promise rejected 🤯'
68 | }
69 | );
70 | };
71 |
72 | updateToast = () => toast.update(this.toastId, { progress: this.state.progress });
73 |
74 | handleAutoCloseDelay = e =>
75 | this.setState({
76 | autoClose: e.target.value > 0 ? parseInt(e.target.value, 10) : 1
77 | });
78 |
79 | isDefaultProps() {
80 | return (
81 | this.state.position === 'top-right' &&
82 | this.state.autoClose === 5000 &&
83 | !this.state.disableAutoClose &&
84 | !this.state.hideProgressBar &&
85 | !this.state.newestOnTop &&
86 | !this.state.rtl &&
87 | this.state.pauseOnFocusLoss &&
88 | this.state.pauseOnHover &&
89 | this.state.closeOnClick &&
90 | this.state.draggable &&
91 | this.state.theme === 'light'
92 | );
93 | }
94 |
95 | handleRadioOrSelect = e =>
96 | this.setState({
97 | [e.target.name]: e.target.name === 'limit' ? parseInt(e.target.value, 10) : e.target.value
98 | });
99 |
100 | toggleCheckbox = e =>
101 | this.setState({
102 | [e.target.name]: !this.state[e.target.name]
103 | });
104 |
105 | renderFlags() {
106 | return flags.map(({ id, label }) => (
107 |
108 |
109 |
110 | ));
111 | }
112 |
113 | render() {
114 | return (
115 |
116 |
117 |
118 |
119 | By default, all toasts will inherit ToastContainer's props. Props defined on toast supersede
120 | ToastContainer's props. Props marked with * can only be set on the ToastContainer. The demo is not
121 | exhaustive, check the repo for more!
122 |
123 |
124 |
125 |
Position
126 |
134 |
135 |
141 |
204 |
205 |
212 |
213 |
214 |
215 |
216 |
217 | 🚀
218 | {' '}
219 | Show Toast
220 |
221 |
222 |
223 |
224 | Promise
225 |
226 |
227 |
228 |
229 | Update
230 |
231 |
232 |
233 |
234 |
235 | 💩
236 | {' '}
237 | Clear All
238 |
239 |
240 |
241 |
242 |
243 | 🔄
244 | {' '}
245 | Reset
246 |
247 |
248 |
249 |
250 |
251 |
256 |
257 |
258 |
259 | );
260 | }
261 | }
262 |
263 | export { App };
264 |
--------------------------------------------------------------------------------
/playground/src/components/Checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | interface CheckboxProps {
4 | label: string;
5 | id: string;
6 | checked: boolean;
7 | onChange: (e: React.ChangeEvent) => void;
8 | }
9 |
10 | export const Checkbox = ({ label, onChange, id, checked }: CheckboxProps) => (
11 |
12 |
19 | {label}
20 |
21 | );
22 |
--------------------------------------------------------------------------------
/playground/src/components/ContainerCode.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ToastContainerProps } from '../../../src';
3 |
4 | function getProp(prop: L, value: R) {
5 | return value ? (
6 |
7 | {prop}
8 |
9 | ) : (
10 |
11 | {prop}
12 | {`={false}`}
13 |
14 | );
15 | }
16 |
17 | export interface ContainerCodeProps extends Partial {
18 | isDefaultProps: boolean;
19 | disableAutoClose: boolean;
20 | }
21 |
22 | export const ContainerCode: React.FC = ({
23 | position,
24 | disableAutoClose,
25 | autoClose,
26 | hideProgressBar,
27 | newestOnTop,
28 | closeOnClick,
29 | pauseOnHover,
30 | rtl,
31 | pauseOnFocusLoss,
32 | isDefaultProps,
33 | draggable,
34 | theme
35 | }) => (
36 |
37 |
Toast Container
38 |
39 |
40 | {`<`}
41 | ToastContainer
42 |
43 |
44 | position
45 | {`="${position}"`}
46 |
47 |
48 | theme
49 | {`="${theme}"`}
50 |
51 |
52 | autoClose
53 | {`={${disableAutoClose ? false : autoClose}}`}
54 |
55 | {!disableAutoClose ? getProp('hideProgressBar', hideProgressBar) : ''}
56 | {getProp('newestOnTop', newestOnTop)}
57 | {getProp('closeOnClick', closeOnClick)}
58 | {getProp('rtl', rtl)}
59 | {getProp('pauseOnFocusLoss', pauseOnFocusLoss)}
60 | {getProp('draggable', draggable)}
61 | {!disableAutoClose ? getProp('pauseOnHover', pauseOnHover) : ''}
62 |
63 | {`/>`}
64 |
65 | {isDefaultProps && (
66 |
67 |
{`{/* Same as */}`}
68 |
{`<`}
69 |
ToastContainer
70 |
{'/>'}
71 |
72 | )}
73 |
74 |
75 | );
76 |
--------------------------------------------------------------------------------
/playground/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export const Header = () => (
4 |
59 | );
60 |
--------------------------------------------------------------------------------
/playground/src/components/Radio.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | interface RadioProps {
4 | options: Record;
5 | name: string;
6 | onChange: (e: React.ChangeEvent) => void;
7 | checked: string | boolean;
8 | }
9 |
10 | export const Radio = ({
11 | options,
12 | name,
13 | onChange,
14 | checked = false
15 | }: RadioProps) => (
16 | <>
17 | {Object.keys(options).map(k => {
18 | const option = options[k];
19 |
20 | return (
21 |
22 |
23 |
31 | {option}
32 |
33 |
34 | );
35 | })}
36 | >
37 | );
38 |
--------------------------------------------------------------------------------
/playground/src/components/ToastCode.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { themes } from './constants';
4 |
5 | function getType(type: string) {
6 | switch (type) {
7 | case 'default':
8 | default:
9 | return 'toast';
10 | case 'success':
11 | return 'toast.success';
12 | case 'error':
13 | return 'toast.error';
14 | case 'info':
15 | return 'toast.info';
16 | case 'warning':
17 | return 'toast.warn';
18 | }
19 | }
20 |
21 | export interface ToastCodeProps {
22 | position: string;
23 | disableAutoClose: boolean;
24 | autoClose: boolean | number;
25 | hideProgressBar: boolean;
26 | closeOnClick: boolean;
27 | pauseOnHover: boolean;
28 | type: string;
29 | draggable: boolean;
30 | progress: number;
31 | theme: typeof themes[number];
32 | }
33 |
34 | export const ToastCode: React.FC = ({
35 | position,
36 | disableAutoClose,
37 | autoClose,
38 | hideProgressBar,
39 | closeOnClick,
40 | pauseOnHover,
41 | type,
42 | draggable,
43 | progress,
44 | theme
45 | }) => (
46 |
47 |
Toast Emitter
48 |
49 |
50 | {getType(type)}
51 | {`('🦄 Wow so easy!', { `}
52 |
53 |
54 | position
55 | {`: "${position}"`},
56 |
57 |
58 | theme
59 | {`: "${theme}"`},
60 |
61 |
62 | autoClose
63 | {`: ${disableAutoClose ? false : autoClose}`},
64 |
65 |
66 | hideProgressBar
67 | {`: ${hideProgressBar ? 'true' : 'false'}`},
68 |
69 |
70 | closeOnClick
71 | {`: ${closeOnClick ? 'true' : 'false'}`},
72 |
73 |
74 | pauseOnHover
75 | {`: ${pauseOnHover ? 'true' : 'false'}`},
76 |
77 |
78 | draggable
79 | {`: ${draggable ? 'true' : 'false'}`},
80 |
81 | {!Number.isNaN(progress) && (
82 |
83 | progress
84 | {`: ${progress}`},
85 |
86 | )}
87 |
{`});`}
88 |
89 |
90 | );
91 |
--------------------------------------------------------------------------------
/playground/src/components/constants.ts:
--------------------------------------------------------------------------------
1 | import { Bounce, Slide, Flip, Zoom } from '../../../src/index';
2 |
3 | export const flags = [
4 | {
5 | id: 'disableAutoClose',
6 | label: 'Disable auto-close'
7 | },
8 | {
9 | id: 'hideProgressBar',
10 | label: 'Hide progress bar(less fanciness!)'
11 | },
12 | {
13 | id: 'newestOnTop',
14 | label: 'Newest on top*'
15 | },
16 | {
17 | id: 'closeOnClick',
18 | label: 'Close on click'
19 | },
20 | {
21 | id: 'pauseOnHover',
22 | label: 'Pause delay on hover'
23 | },
24 | {
25 | id: 'pauseOnFocusLoss',
26 | label: 'Pause toast when the window loses focus'
27 | },
28 | {
29 | id: 'rtl',
30 | label: 'Right to left layout*'
31 | },
32 | {
33 | id: 'draggable',
34 | label: 'Allow to drag and close the toast'
35 | }
36 | ];
37 |
38 | export const transitions = {
39 | bounce: Bounce,
40 | slide: Slide,
41 | zoom: Zoom,
42 | flip: Flip
43 | };
44 |
45 | export const themes = ['light', 'dark', 'colored'];
46 |
47 | export const positions = {
48 | TOP_LEFT: 'top-left',
49 | TOP_RIGHT: 'top-right',
50 | TOP_CENTER: 'top-center',
51 | BOTTOM_LEFT: 'bottom-left',
52 | BOTTOM_RIGHT: 'bottom-right',
53 | BOTTOM_CENTER: 'bottom-center'
54 | };
55 |
56 | export const typs = {
57 | INFO: 'info',
58 | SUCCESS: 'success',
59 | WARNING: 'warning',
60 | ERROR: 'error',
61 | DEFAULT: 'default'
62 | };
63 |
--------------------------------------------------------------------------------
/playground/src/index.css:
--------------------------------------------------------------------------------
1 | @import url(https://fonts.googleapis.com/css?family=Titillium+Web);
2 | body {
3 | margin: 0;
4 | padding: 0;
5 | font-family: 'Titillium Web', sans-serif;
6 | min-height: 100vh;
7 | background: linear-gradient(110deg, #1d4350, #a43931);
8 | color: #fff;
9 | }
10 |
11 | * {
12 | box-sizing: border-box;
13 | }
14 |
15 | main {
16 | display: grid;
17 | grid-template-rows: auto 1fr;
18 | grid-gap: 20px;
19 | }
20 |
21 | header {
22 | background: #222;
23 | text-align: center;
24 | padding: 30px 0;
25 | }
26 |
27 | h3 {
28 | color: #fff;
29 | }
30 |
31 | header h1 {
32 | margin-top: 0;
33 | }
34 |
35 | ul {
36 | list-style: none;
37 | padding: 0;
38 | }
39 |
40 | input[type='number'] {
41 | padding: 8px;
42 | background-color: transparent;
43 | box-shadow: none;
44 | border: 1px solid;
45 | margin: 0 5px;
46 | border-radius: 5px;
47 | border-color: #ac557b;
48 | color: #fff;
49 | width: 100px;
50 | }
51 |
52 | input[type='radio'] {
53 | margin-right: 8px;
54 | }
55 |
56 | select {
57 | padding: 8px;
58 | padding: 8px;
59 | background-color: transparent;
60 | box-shadow: none;
61 | border: 1px solid;
62 | border-top-color: currentcolor;
63 | border-right-color: currentcolor;
64 | border-bottom-color: currentcolor;
65 | border-left-color: currentcolor;
66 | border-top-color: currentcolor;
67 | border-right-color: currentcolor;
68 | border-bottom-color: currentcolor;
69 | border-left-color: currentcolor;
70 | margin: 0 5px;
71 | border-radius: 5px;
72 | border-color: #ac557b;
73 | color: #fff;
74 | -webkit-appearance: none;
75 | -moz-appearance: none;
76 | }
77 |
78 | .container {
79 | max-width: 1080px;
80 | margin: auto;
81 | width: 100%;
82 | background: rgba(255, 255, 255, 0.1);
83 | padding: 20px;
84 | border-radius: 10px;
85 | display: grid;
86 | grid-template-columns: repeat(2, 1fr);
87 | gap: 10px;
88 | }
89 |
90 | .container p {
91 | grid-column: 1 / -1;
92 | font-size: 13px;
93 | font-style: italic;
94 | background: #222;
95 | padding: 5px;
96 | border-left: 3px solid #a9547e;
97 | }
98 |
99 | .container__options {
100 | display: grid;
101 | grid-template-columns: repeat(2, 1fr);
102 | }
103 |
104 | .container__options div:last-child {
105 | grid-column: 1 / -1;
106 | }
107 |
108 | .container__actions {
109 | display: flex;
110 | }
111 |
112 | .cta__wrapper {
113 | grid-column: span 2;
114 | }
115 |
116 | .btn {
117 | color: #fff;
118 | text-decoration: none;
119 | padding: 8px 16px;
120 | margin: 0 15px 0 0;
121 | background: linear-gradient(100deg, #e96443, #904e95);
122 | box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12),
123 | 0 3px 1px -2px rgba(0, 0, 0, 0.2);
124 | border: none;
125 | text-transform: capitalize;
126 | cursor: pointer;
127 | transition: transform 0.3s;
128 | min-width: 120px;
129 | }
130 |
131 | .btn:hover {
132 | transform: scale(1.1);
133 | }
134 |
135 | .bg-red {
136 | background: #d13c3c;
137 | }
138 |
139 | .bg-blue {
140 | background: #3b4149;
141 | }
142 |
143 | .code {
144 | font-family: 'Source Code Pro', Menlo, Monaco, Courier, monospace;
145 | font-size: 12px;
146 | line-height: 1.4;
147 | font-style: normal;
148 | border-left: 3px solid #a9547e;
149 | padding-left: 20px;
150 | background: #222;
151 | }
152 |
153 | .code__component {
154 | color: #66d9ef;
155 | }
156 |
157 | .code__props {
158 | color: #a6e22e;
159 | }
160 |
161 | .code div {
162 | margin-left: 20px;
163 | }
164 |
165 | .code div:first-child,
166 | .code div:last-child {
167 | margin: 0;
168 | }
169 |
170 | .github-corner:hover .octo-arm {
171 | animation: octocat-wave 560ms ease-in-out;
172 | }
173 |
174 | .options_wrapper {
175 | display: grid;
176 | grid-template-columns: repeat(3, auto);
177 | gap: 18px;
178 | }
179 |
180 | @keyframes octocat-wave {
181 | 0%,
182 | 100% {
183 | transform: rotate(0);
184 | }
185 | 20%,
186 | 60% {
187 | transform: rotate(-25deg);
188 | }
189 | 40%,
190 | 80% {
191 | transform: rotate(10deg);
192 | }
193 | }
194 |
195 | @media (max-width: 500px) {
196 | .github-corner:hover .octo-arm {
197 | animation: none;
198 | }
199 | .github-corner .octo-arm {
200 | animation: octocat-wave 560ms ease-in-out;
201 | }
202 | }
203 |
--------------------------------------------------------------------------------
/playground/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { App } from './components/App';
4 | import './index.css';
5 |
6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/playground/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/playground/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx",
15 |
16 | /* Linting */
17 | "strict": false,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "noFallthroughCasesInSwitch": true
21 | },
22 | "include": ["src"],
23 | "references": [{ "path": "./tsconfig.node.json" }]
24 | }
25 |
--------------------------------------------------------------------------------
/playground/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/playground/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/src/addons/use-notification-center/NotificationCenter.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { toast, ToastContainer } from 'react-toastify';
4 | import { NotificationCenterItem, useNotificationCenter, UseNotificationCenterParams } from './useNotificationCenter';
5 |
6 | function TestComponent(props: UseNotificationCenterParams) {
7 | const [content, setContent] = React.useState('');
8 | const [updateId, setUpdateId] = React.useState('');
9 | const { unreadCount, markAllAsRead, markAsRead, notifications, remove, add, clear, update } = useNotificationCenter(
10 | props || {}
11 | );
12 |
13 | const flex = {
14 | display: 'flex',
15 | gap: '1rem',
16 | alignItems: 'center'
17 | };
18 |
19 | return (
20 |
21 |
22 | toast('hello')}>display notification
23 | markAllAsRead
24 | clear
25 | add({ content })}>addNotification
26 | update(updateId, { content })}>updateNotification
27 |
28 |
29 |
30 | count
31 | {notifications.length}
32 |
33 |
34 | unread count
35 | {unreadCount}
36 |
37 |
38 |
39 |
setContent(e.target.value)} value={content} />
40 |
setUpdateId(e.target.value)} value={updateId} />
41 |
42 |
43 | {notifications.map(el => (
44 |
45 | {/* @ts-ignore */}
46 | {el.content}
47 | {el.read.toString()}
48 | markAsRead(el.id)}>
49 | markAsRead
50 |
51 | remove(el.id)}>
52 | remove
53 |
54 |
55 | ))}
56 |
57 |
58 |
59 | );
60 | }
61 |
62 | describe('NotificationCenter', () => {
63 | beforeEach(() => {
64 | cy.mount( );
65 | });
66 |
67 | it('listen for new notifications', () => {
68 | cy.findByTestId('count').should('contain.text', 0);
69 | cy.findByTestId('unreadCount').should('contain.text', 0);
70 |
71 | // hacky asf???
72 | cy.wait(1000).then(() => {
73 | toast('msg');
74 | cy.findByTestId('count').should('contain.text', 1, { timeout: 10000 });
75 | cy.findByTestId('unreadCount').should('contain.text', 1);
76 | });
77 | });
78 |
79 | it('add notification', () => {
80 | cy.findByTestId('count').should('contain.text', 0);
81 | cy.findByTestId('unreadCount').should('contain.text', 0);
82 |
83 | cy.findByTestId('content').type('something');
84 | cy.findByText('addNotification').click();
85 |
86 | cy.findByText('something').should('exist');
87 | cy.findByTestId('count').should('contain.text', 1);
88 | cy.findByTestId('unreadCount').should('contain.text', 1);
89 | });
90 |
91 | it('update', () => {
92 | const id = toast('msg');
93 |
94 | cy.resolveEntranceAnimation();
95 | cy.findByRole('alert').should('exist');
96 |
97 | setTimeout(() => {
98 | toast.update(id, {
99 | render: 'msg updated'
100 | });
101 | }, 0);
102 |
103 | cy.findAllByText('msg updated').should('exist');
104 | });
105 |
106 | describe('with initial state', () => {
107 | const initialState: NotificationCenterItem[] = [
108 | {
109 | id: 1,
110 | createdAt: Date.now(),
111 | read: false,
112 | content: 'noti1'
113 | },
114 | {
115 | id: 2,
116 | createdAt: Date.now(),
117 | read: true,
118 | content: 'noti2'
119 | }
120 | ];
121 |
122 | beforeEach(() => {
123 | cy.mount( );
124 | });
125 |
126 | it('handle initial state', () => {
127 | cy.findByTestId('count').should('contain.text', initialState.length);
128 |
129 | cy.findByTestId('unreadCount').should('contain.text', 1);
130 |
131 | initialState.forEach(v => {
132 | cy.findByText(v.content as string).should('exist');
133 | });
134 | });
135 |
136 | it('clear all', () => {
137 | cy.findByTestId('count').should('contain.text', initialState.length);
138 | cy.findByTestId('unreadCount').should('contain.text', 1);
139 |
140 | cy.findByText('clear').click();
141 |
142 | cy.findByTestId('count').should('contain.text', 0);
143 | cy.findByTestId('unreadCount').should('contain.text', 0);
144 | });
145 |
146 | it('mark all as read', () => {
147 | cy.findByTestId('unreadCount').should('contain.text', 1);
148 |
149 | cy.findByText('markAllAsRead').click();
150 |
151 | cy.findByTestId('unreadCount').should('contain.text', 0);
152 | });
153 | });
154 | });
155 |
--------------------------------------------------------------------------------
/src/addons/use-notification-center/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useNotificationCenter';
2 |
--------------------------------------------------------------------------------
/src/addons/use-notification-center/useNotificationCenter.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from 'react';
2 | import { toast, ToastItem, Id } from 'react-toastify';
3 |
4 | type Optional = Pick, K> & Omit;
5 |
6 | export interface NotificationCenterItem extends Optional, 'content' | 'data' | 'status'> {
7 | read: boolean;
8 | createdAt: number;
9 | }
10 |
11 | export type SortFn = (l: NotificationCenterItem, r: NotificationCenterItem) => number;
12 |
13 | export type FilterFn = (item: NotificationCenterItem) => boolean;
14 |
15 | export interface UseNotificationCenterParams {
16 | /**
17 | * initial data to rehydrate the notification center
18 | */
19 | data?: NotificationCenterItem[];
20 |
21 | /**
22 | * By default, the notifications are sorted from the newest to the oldest using
23 | * the `createdAt` field. Use this to provide your own sort function
24 | *
25 | * Usage:
26 | * ```
27 | * // old notifications first
28 | * useNotificationCenter({
29 | * sort: ((l, r) => l.createdAt - r.createdAt)
30 | * })
31 | * ```
32 | */
33 | sort?: SortFn;
34 |
35 | /**
36 | * Keep the toast that meets the condition specified in the callback function.
37 | *
38 | * Usage:
39 | * ```
40 | * // keep only the toasts when hidden is set to false
41 | * useNotificationCenter({
42 | * filter: item => item.data.hidden === false
43 | * })
44 | * ```
45 | */
46 | filter?: FilterFn;
47 | }
48 |
49 | export interface UseNotificationCenter {
50 | /**
51 | * Contains all the notifications
52 | */
53 | notifications: NotificationCenterItem[];
54 |
55 | /**
56 | * Clear all notifications
57 | */
58 | clear(): void;
59 |
60 | /**
61 | * Mark all notification as read
62 | */
63 | markAllAsRead(): void;
64 |
65 | /**
66 | * Mark all notification as read or not.
67 | *
68 | * Usage:
69 | * ```
70 | * markAllAsRead(false) // mark all notification as not read
71 | *
72 | * markAllAsRead(true) // same as calling markAllAsRead()
73 | * ```
74 | */
75 | markAllAsRead(read?: boolean): void;
76 |
77 | /**
78 | * Mark one or more notifications as read.
79 | *
80 | * Usage:
81 | * ```
82 | * markAsRead("anId")
83 | * markAsRead(["a","list", "of", "id"])
84 | * ```
85 | */
86 | markAsRead(id: Id | Id[]): void;
87 |
88 | /**
89 | * Mark one or more notifications as read.The second parameter let you mark the notification as read or not.
90 | *
91 | * Usage:
92 | * ```
93 | * markAsRead("anId", false)
94 | * markAsRead(["a","list", "of", "id"], false)
95 | *
96 | * markAsRead("anId", true) // same as markAsRead("anId")
97 | * ```
98 | */
99 | markAsRead(id: Id | Id[], read?: boolean): void;
100 |
101 | /**
102 | * Remove one or more notifications
103 | *
104 | * Usage:
105 | * ```
106 | * remove("anId")
107 | * remove(["a","list", "of", "id"])
108 | * ```
109 | */
110 | remove(id: Id | Id[]): void;
111 |
112 | /**
113 | * Push a notification to the notification center.
114 | * Returns null when an item with the given id already exists
115 | *
116 | * Usage:
117 | * ```
118 | * const id = add({id: "id", content: "test", data: { foo: "hello" } })
119 | *
120 | * // Return the id of the notification, generate one if none provided
121 | * const id = add({ data: {title: "a title", text: "some text"} })
122 | * ```
123 | */
124 | add(item: Partial>): Id | null;
125 |
126 | /**
127 | * Update the notification that match the id
128 | * Returns null when no matching notification found
129 | *
130 | * Usage:
131 | * ```
132 | * const id = update("anId", {content: "test", data: { foo: "hello" } })
133 | *
134 | * // It's also possible to update the id
135 | * const id = update("anId"m { id:"anotherOne", data: {title: "a title", text: "some text"} })
136 | * ```
137 | */
138 | update(id: Id, item: Partial>): Id | null;
139 |
140 | /**
141 | * Retrieve one or more notifications
142 | *
143 | * Usage:
144 | * ```
145 | * find("anId")
146 | * find(["a","list", "of", "id"])
147 | * ```
148 | */
149 | find(id: Id): NotificationCenterItem | undefined;
150 |
151 | /**
152 | * Retrieve one or more notifications
153 | *
154 | * Usage:
155 | * ```
156 | * find("anId")
157 | * find(["a","list", "of", "id"])
158 | * ```
159 | */
160 | find(id: Id[]): NotificationCenterItem[] | undefined;
161 |
162 | /**
163 | * Retrieve the count for unread notifications
164 | */
165 | unreadCount: number;
166 |
167 | /**
168 | * Sort notifications using the newly provided function
169 | *
170 | * Usage:
171 | * ```
172 | * // old notifications first
173 | * sort((l, r) => l.createdAt - r.createdAt)
174 | * ```
175 | */
176 | sort(sort: SortFn): void;
177 | }
178 |
179 | export function useNotificationCenter(
180 | params: UseNotificationCenterParams = {}
181 | ): UseNotificationCenter {
182 | const sortFn = useRef(params.sort || defaultSort);
183 | const filterFn = useRef(params.filter || null);
184 | const [notifications, setNotifications] = useState[]>(() => {
185 | if (params.data) {
186 | return filterFn.current
187 | ? params.data.filter(filterFn.current).sort(sortFn.current)
188 | : [...params.data].sort(sortFn.current);
189 | }
190 | return [];
191 | });
192 |
193 | useEffect(() => {
194 | return toast.onChange(item => {
195 | if (item.status === 'added' || item.status === 'updated') {
196 | const newItem = decorate(item as NotificationCenterItem);
197 | if (filterFn.current && !filterFn.current(newItem)) return;
198 |
199 | setNotifications(prev => {
200 | let nextState: NotificationCenterItem[] = [];
201 | const updateIdx = prev.findIndex(v => v.id === newItem.id);
202 |
203 | if (updateIdx !== -1) {
204 | nextState = prev.slice();
205 | Object.assign(nextState[updateIdx], newItem, {
206 | createdAt: Date.now()
207 | });
208 | } else if (prev.length === 0) {
209 | nextState = [newItem];
210 | } else {
211 | nextState = [newItem, ...prev];
212 | }
213 | return nextState.sort(sortFn.current);
214 | });
215 | }
216 | });
217 | }, []);
218 |
219 | const remove = (id: Id | Id[]) => {
220 | setNotifications(prev => prev.filter(Array.isArray(id) ? v => !id.includes(v.id) : v => v.id !== id));
221 | };
222 |
223 | const clear = () => {
224 | setNotifications([]);
225 | };
226 |
227 | const markAllAsRead = (read = true) => {
228 | setNotifications(prev =>
229 | prev.map(v => {
230 | v.read = read;
231 | return v;
232 | })
233 | );
234 | };
235 |
236 | const markAsRead = (id: Id | Id[], read = true) => {
237 | let map = (v: NotificationCenterItem) => {
238 | if (v.id === id) v.read = read;
239 | return v;
240 | };
241 |
242 | if (Array.isArray(id)) {
243 | map = v => {
244 | if (id.includes(v.id)) v.read = read;
245 | return v;
246 | };
247 | }
248 |
249 | setNotifications(prev => prev.map(map));
250 | };
251 |
252 | const find = (id: Id | Id[]) => {
253 | return Array.isArray(id) ? notifications.filter(v => id.includes(v.id)) : notifications.find(v => v.id === id);
254 | };
255 |
256 | const add = (item: Partial>) => {
257 | if (notifications.find(v => v.id === item.id)) return null;
258 |
259 | const newItem = decorate(item);
260 |
261 | setNotifications(prev => [...prev, newItem].sort(sortFn.current));
262 |
263 | return newItem.id;
264 | };
265 |
266 | const update = (id: Id, item: Partial>) => {
267 | const index = notifications.findIndex(v => v.id === id);
268 |
269 | if (index !== -1) {
270 | setNotifications(prev => {
271 | const nextState = [...prev];
272 | Object.assign(nextState[index], item, {
273 | createdAt: item.createdAt || Date.now()
274 | });
275 |
276 | return nextState.sort(sortFn.current);
277 | });
278 |
279 | return item.id as Id;
280 | }
281 |
282 | return null;
283 | };
284 |
285 | const sort = (compareFn: SortFn) => {
286 | sortFn.current = compareFn;
287 | setNotifications(prev => prev.slice().sort(compareFn));
288 | };
289 |
290 | return {
291 | notifications,
292 | clear,
293 | markAllAsRead,
294 | markAsRead,
295 | add,
296 | update,
297 | remove,
298 | // @ts-ignore fixme: overloading issue
299 | find,
300 | sort,
301 | get unreadCount() {
302 | return notifications.reduce((prev, cur) => (!cur.read ? prev + 1 : prev), 0);
303 | }
304 | };
305 | }
306 |
307 | export function decorate(item: NotificationCenterItem | Partial>) {
308 | if (item.id == null) item.id = Date.now().toString(36).substring(2, 9);
309 | if (!item.createdAt) item.createdAt = Date.now();
310 | if (item.read == null) item.read = false;
311 | return item as NotificationCenterItem;
312 | }
313 |
314 | // newest to oldest
315 | function defaultSort(l: NotificationCenterItem, r: NotificationCenterItem) {
316 | return r.createdAt - l.createdAt;
317 | }
318 |
--------------------------------------------------------------------------------
/src/components/CloseButton.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { CloseButton } from './CloseButton';
3 |
4 | describe('CloseButton', () => {
5 | it('call close toast when clicking', () => {
6 | const closeToast = cy.stub().as('closeToast');
7 | cy.mount( );
8 |
9 | cy.get('@closeToast').should('not.have.been.called');
10 | cy.findByRole('button').click();
11 | cy.get('@closeToast').should('have.been.called');
12 | });
13 |
14 | it('have a default aria-label', () => {
15 | cy.mount( );
16 |
17 | cy.findByLabelText('close').should('exist');
18 | });
19 |
20 | it('set aria-label', () => {
21 | cy.mount( );
22 |
23 | cy.findByLabelText('foobar').should('exist');
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/CloseButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Default } from '../utils';
3 | import { CloseToastFunc, Theme, TypeOptions } from '../types';
4 |
5 | export interface CloseButtonProps {
6 | closeToast: CloseToastFunc;
7 | type: TypeOptions;
8 | ariaLabel?: string;
9 | theme: Theme;
10 | }
11 |
12 | export function CloseButton({ closeToast, theme, ariaLabel = 'close' }: CloseButtonProps) {
13 | return (
14 | {
18 | e.stopPropagation();
19 | closeToast(true);
20 | }}
21 | aria-label={ariaLabel}
22 | >
23 |
24 |
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/Icons.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { TypeOptions } from '../types';
3 | import { IconParams, getIcon } from './Icons';
4 |
5 | const props: IconParams = {
6 | theme: 'light',
7 | type: 'default',
8 | isLoading: false
9 | };
10 |
11 | describe('Icons', () => {
12 | it('handle function', () => {
13 | const C = getIcon({
14 | ...props,
15 | icon: () => icon
16 | });
17 |
18 | cy.mount(C);
19 | cy.findByText('icon').should('exist');
20 | });
21 |
22 | it('handle react element', () => {
23 | const C = getIcon({
24 | ...props,
25 | icon: icon
26 | });
27 |
28 | cy.mount(C);
29 | cy.findByText('icon').should('exist');
30 | });
31 |
32 | it('handle loader', () => {
33 | const C = getIcon({
34 | ...props,
35 | isLoading: true
36 | });
37 |
38 | cy.mount(C);
39 | cy.get('[data-cy-root]').should('have.length', 1);
40 | });
41 |
42 | it('handle built-in icons', () => {
43 | for (const t of ['info', 'warning', 'success', 'error', 'spinner']) {
44 | const C = getIcon({
45 | ...props,
46 | type: t as TypeOptions
47 | });
48 |
49 | cy.mount(C);
50 | cy.get('[data-cy-root]').should('have.length', 1);
51 | }
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/components/Icons.tsx:
--------------------------------------------------------------------------------
1 | import React, { cloneElement, isValidElement } from 'react';
2 |
3 | import { Theme, ToastProps, TypeOptions } from '../types';
4 | import { Default, isFn } from '../utils';
5 |
6 | /**
7 | * Used when providing custom icon
8 | */
9 | export interface IconProps {
10 | theme: Theme;
11 | type: TypeOptions;
12 | isLoading?: boolean;
13 | }
14 |
15 | export type BuiltInIconProps = React.SVGProps & IconProps;
16 |
17 | const Svg: React.FC = ({ theme, type, isLoading, ...rest }) => (
18 |
25 | );
26 |
27 | function Warning(props: BuiltInIconProps) {
28 | return (
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | function Info(props: BuiltInIconProps) {
36 | return (
37 |
38 |
39 |
40 | );
41 | }
42 |
43 | function Success(props: BuiltInIconProps) {
44 | return (
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | function Error(props: BuiltInIconProps) {
52 | return (
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | function Spinner() {
60 | return
;
61 | }
62 |
63 | export const Icons = {
64 | info: Info,
65 | warning: Warning,
66 | success: Success,
67 | error: Error,
68 | spinner: Spinner
69 | };
70 |
71 | const maybeIcon = (type: string): type is keyof typeof Icons => type in Icons;
72 |
73 | export type IconParams = Pick;
74 |
75 | export function getIcon({ theme, type, isLoading, icon }: IconParams) {
76 | let Icon: React.ReactNode = null;
77 | const iconProps = { theme, type };
78 |
79 | if (icon === false) {
80 | // hide
81 | } else if (isFn(icon)) {
82 | Icon = icon({ ...iconProps, isLoading });
83 | } else if (isValidElement(icon)) {
84 | Icon = cloneElement(icon, iconProps);
85 | } else if (isLoading) {
86 | Icon = Icons.spinner();
87 | } else if (maybeIcon(type)) {
88 | Icon = Icons[type](iconProps);
89 | }
90 |
91 | return Icon;
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/ProgressBar.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Theme } from '../types';
3 | import { ProgressBar } from './ProgressBar';
4 |
5 | const getProps = () => ({
6 | delay: 5000,
7 | isRunning: true,
8 | rtl: false,
9 | closeToast: cy.stub,
10 | isIn: true,
11 | theme: ['colored', 'light', 'dark'][Math.floor(Math.random() * 3)] as Theme
12 | });
13 |
14 | const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
15 |
25 | {children}
26 |
27 | );
28 |
29 | describe('ProgressBar', () => {
30 | it('merge className', () => {
31 | cy.mount(
32 |
33 |
34 |
35 | );
36 |
37 | cy.get('.test').should('exist');
38 | });
39 |
40 | it('merge className in function form', () => {
41 | cy.mount(
42 |
43 | 'test'} />
44 |
45 | );
46 |
47 | cy.get('.test').should('exist');
48 | });
49 |
50 | it('trigger closeToast when animation end', () => {
51 | const closeToast = cy.stub().as('closeToast');
52 | const delay = 1000;
53 | cy.mount(
54 |
55 |
56 |
57 | );
58 |
59 | cy.get('@closeToast').should('not.have.been.called');
60 | cy.wait(delay);
61 | cy.get('@closeToast').should('have.been.called');
62 | });
63 |
64 | it('hide the progress bar', () => {
65 | cy.mount(
66 |
67 |
68 |
69 | );
70 |
71 | cy.get('[role=progressbar]').should('exist').should('not.be.visible');
72 | });
73 |
74 | it('pause the progress bar', () => {
75 | cy.mount(
76 |
77 |
78 |
79 | );
80 |
81 | cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'animation-play-state: paused');
82 | });
83 |
84 | it('control progress bar', () => {
85 | cy.mount(
86 |
87 |
88 |
89 | );
90 |
91 | cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'scaleX(0.7)');
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/src/components/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import cx from 'clsx';
3 |
4 | import { Default, isFn, Type } from '../utils';
5 | import { Theme, ToastClassName, TypeOptions } from '../types';
6 |
7 | export interface ProgressBarProps {
8 | /**
9 | * The animation delay which determine when to close the toast
10 | */
11 | delay: number;
12 |
13 | /**
14 | * The animation is running or paused
15 | */
16 | isRunning: boolean;
17 |
18 | /**
19 | * Func to close the current toast
20 | */
21 | closeToast: () => void;
22 |
23 | /**
24 | * Optional type : info, success ...
25 | */
26 | type?: TypeOptions;
27 |
28 | /**
29 | * The theme that is currently used
30 | */
31 | theme: Theme;
32 |
33 | /**
34 | * Hide or not the progress bar
35 | */
36 | hide?: boolean;
37 |
38 | /**
39 | * Optional className
40 | */
41 | className?: ToastClassName;
42 |
43 | /**
44 | * Tell whether a controlled progress bar is used
45 | */
46 | controlledProgress?: boolean;
47 |
48 | /**
49 | * Controlled progress value
50 | */
51 | progress?: number | string;
52 |
53 | /**
54 | * Support rtl content
55 | */
56 | rtl?: boolean;
57 |
58 | /**
59 | * Tell if the component is visible on screen or not
60 | */
61 | isIn?: boolean;
62 | }
63 |
64 | export function ProgressBar({
65 | delay,
66 | isRunning,
67 | closeToast,
68 | type = Type.DEFAULT,
69 | hide,
70 | className,
71 | controlledProgress,
72 | progress,
73 | rtl,
74 | isIn,
75 | theme
76 | }: ProgressBarProps) {
77 | const isHidden = hide || (controlledProgress && progress === 0);
78 | const style: React.CSSProperties = {
79 | animationDuration: `${delay}ms`,
80 | animationPlayState: isRunning ? 'running' : 'paused'
81 | };
82 |
83 | if (controlledProgress) style.transform = `scaleX(${progress})`;
84 | const defaultClassName = cx(
85 | `${Default.CSS_NAMESPACE}__progress-bar`,
86 | controlledProgress
87 | ? `${Default.CSS_NAMESPACE}__progress-bar--controlled`
88 | : `${Default.CSS_NAMESPACE}__progress-bar--animated`,
89 | `${Default.CSS_NAMESPACE}__progress-bar-theme--${theme}`,
90 | `${Default.CSS_NAMESPACE}__progress-bar--${type}`,
91 | {
92 | [`${Default.CSS_NAMESPACE}__progress-bar--rtl`]: rtl
93 | }
94 | );
95 | const classNames = isFn(className)
96 | ? className({
97 | rtl,
98 | type,
99 | defaultClassName
100 | })
101 | : cx(defaultClassName, className);
102 |
103 | // 🧐 controlledProgress is derived from progress
104 | // so if controlledProgress is set
105 | // it means that this is also the case for progress
106 | const animationEvent = {
107 | [controlledProgress && (progress as number)! >= 1 ? 'onTransitionEnd' : 'onAnimationEnd']:
108 | controlledProgress && (progress as number)! < 1
109 | ? null
110 | : () => {
111 | isIn && closeToast();
112 | }
113 | };
114 |
115 | // TODO: add aria-valuenow, aria-valuemax, aria-valuemin
116 |
117 | return (
118 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/src/components/Toast.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { DraggableDirection, ToastProps } from '../types';
3 | import { Default } from '../utils';
4 | import { Toast } from './Toast';
5 | import { defaultProps } from './ToastContainer';
6 |
7 | const REQUIRED_PROPS = {
8 | ...defaultProps,
9 | isIn: true,
10 | autoClose: false,
11 | closeToast: () => {},
12 | type: 'default',
13 | toastId: 'id',
14 | key: 'key',
15 | collapseAll: () => {}
16 | } as ToastProps;
17 |
18 | const cssClasses = {
19 | rtl: `.${Default.CSS_NAMESPACE}__toast--rtl`,
20 | closeOnClick: `.${Default.CSS_NAMESPACE}__toast--close-on-click`,
21 | progressBar: `.${Default.CSS_NAMESPACE}__progress-bar`,
22 | progressBarController: `.${Default.CSS_NAMESPACE}__progress-bar--controlled`,
23 | closeButton: `.${Default.CSS_NAMESPACE}__close-button`,
24 | container: `.${Default.CSS_NAMESPACE}__toast-container`
25 | };
26 |
27 | const progressBar = {
28 | isRunning: () => {
29 | cy.wait(100);
30 | cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'animation-play-state: running');
31 | },
32 | isPaused: () => {
33 | cy.wait(100);
34 | cy.findByRole('progressbar')
35 | .should('have.attr', 'style')
36 | .and('include', 'animation-play-state: paused')
37 | .as('pause progress bar');
38 | },
39 | isControlled: (progress: number) => {
40 | cy.wait(100);
41 | cy.get(cssClasses.progressBarController).should('exist');
42 | cy.findByRole('progressbar').should('have.attr', 'style').and('include', `scaleX(${progress})`);
43 | }
44 | };
45 |
46 | describe('Toast', () => {
47 | for (const { name, className } of [
48 | {
49 | name: 'string',
50 | className: 'container-class'
51 | },
52 | {
53 | name: 'function',
54 | className: () => 'container-class'
55 | }
56 | ]) {
57 | it(`merge container when using ${name}`, () => {
58 | cy.mount(
59 |
60 | FooBar
61 |
62 | );
63 |
64 | cy.get('.container-class').should('exist');
65 | });
66 | }
67 |
68 | it('support rtl', () => {
69 | cy.mount(
70 |
71 | FooBar
72 |
73 | );
74 |
75 | cy.get(cssClasses.rtl).should('have.css', 'direction', 'rtl');
76 | });
77 |
78 | describe('closeOnClick', () => {
79 | it('call closeToast when enabled', () => {
80 | const closeToast = cy.stub().as('closeToast');
81 |
82 | cy.mount(
83 |
84 | FooBar
85 |
86 | );
87 |
88 | cy.findByRole('alert').click();
89 | cy.get('@closeToast').should('have.been.called');
90 | });
91 |
92 | it('does not call closeToast when disabled', () => {
93 | const closeToast = cy.stub().as('closeToast');
94 |
95 | cy.mount(
96 |
97 | FooBar
98 |
99 | );
100 |
101 | cy.findByRole('alert').click();
102 | cy.get('@closeToast').should('not.have.been.called');
103 | });
104 | });
105 |
106 | describe('autoClose', () => {
107 | it('does not render progress bar when false', () => {
108 | cy.mount(
109 |
110 | FooBar
111 |
112 | );
113 |
114 | cy.findByRole('progressbar').should('not.exist');
115 | });
116 |
117 | it('resume and pause progress bar', () => {
118 | cy.mount(
119 |
120 | hello
121 |
122 | );
123 |
124 | cy.resolveEntranceAnimation();
125 |
126 | cy.findByRole('alert').should('be.visible').trigger('mouseover');
127 | progressBar.isPaused();
128 |
129 | cy.findByRole('alert').trigger('mouseout');
130 | progressBar.isRunning();
131 |
132 | cy.findByRole('alert').trigger('mouseover');
133 | progressBar.isPaused();
134 | });
135 | });
136 |
137 | it('does not render close button when closeButton is false', () => {
138 | cy.mount(
139 |
140 | FooBar
141 |
142 | );
143 |
144 | cy.findByLabelText('close').should('not.exist');
145 | });
146 |
147 | it('resume and pause progress bar when pauseOnFocusLoss is enabled', () => {
148 | cy.mount(
149 |
150 | hello
151 |
152 | );
153 |
154 | cy.resolveEntranceAnimation();
155 | progressBar.isRunning();
156 |
157 | cy.window().blur();
158 | progressBar.isPaused();
159 |
160 | cy.window().focus();
161 | progressBar.isRunning();
162 | });
163 |
164 | it('does not pause progress bar when pauseOnHover is disabled', () => {
165 | cy.mount(
166 |
167 | hello
168 |
169 | );
170 |
171 | cy.resolveEntranceAnimation();
172 |
173 | cy.findByRole('alert').trigger('mouseover');
174 | progressBar.isRunning();
175 | });
176 |
177 | describe('controller progress bar', () => {
178 | it('set the correct progress value bar disregarding autoClose value', () => {
179 | cy.mount(
180 |
181 | hello
182 |
183 | );
184 |
185 | cy.resolveEntranceAnimation();
186 |
187 | progressBar.isControlled(0.3);
188 |
189 | cy.mount(
190 |
191 | hello
192 |
193 | );
194 |
195 | cy.resolveEntranceAnimation();
196 |
197 | progressBar.isControlled(0.3);
198 | });
199 |
200 | it('call closeToast when progress value is >= 1', () => {
201 | const closeToast = cy.stub().as('closeToast');
202 | cy.mount(
203 |
204 | hello
205 |
206 | );
207 |
208 | cy.findByRole('progressbar').trigger('transitionend');
209 | cy.get('@closeToast').should('have.been.called');
210 | });
211 | });
212 |
213 | it('call closeToast when autoClose duration exceeded', () => {
214 | const closeToast = cy.stub().as('closeToast');
215 | cy.mount(
216 |
217 | hello
218 |
219 | );
220 |
221 | cy.get('@closeToast').should('have.been.called');
222 | });
223 |
224 | it('attach specified attributes: role, id, etc...', () => {
225 | const style: React.CSSProperties = {
226 | background: 'purple'
227 | };
228 |
229 | cy.mount(
230 |
231 | hello
232 |
233 | );
234 |
235 | cy.resolveEntranceAnimation();
236 |
237 | cy.findByRole('status').should('exist');
238 | cy.get('#foo').should('exist');
239 |
240 | cy.findByRole('status').should('have.attr', 'style').and('include', 'background: purple');
241 | });
242 |
243 | for (const { type, value } of [
244 | {
245 | type: 'string',
246 | value: 'hello'
247 | },
248 | {
249 | type: 'react element',
250 | value: hello
251 | },
252 | {
253 | type: 'function',
254 | value: () => hello
255 | }
256 | ]) {
257 | it(`render ${type}`, () => {
258 | cy.mount({value} );
259 |
260 | cy.findByText('hello').should('exist');
261 | });
262 | }
263 |
264 | it('override default closeButton', () => {
265 | cy.mount(
266 | 💩}>
267 | hello
268 |
269 | );
270 | cy.resolveEntranceAnimation();
271 |
272 | cy.findByText('💩').should('exist');
273 | });
274 |
275 | it('fallback to default closeButton', () => {
276 | cy.mount(
277 |
278 | hello
279 |
280 | );
281 | cy.resolveEntranceAnimation();
282 |
283 | cy.findByLabelText('close').should('exist');
284 | });
285 |
286 | describe('Drag event', () => {
287 | beforeEach(() => {
288 | cy.viewport('macbook-16');
289 | });
290 |
291 | for (const { axis, delta } of [
292 | { axis: 'x', delta: { deltaX: -300 } },
293 | { axis: 'y', delta: { deltaY: 300 } }
294 | ]) {
295 | it(`close toast when dragging on ${axis}-axis`, () => {
296 | cy.mount(
297 |
298 |
305 | hello
306 |
307 |
308 | );
309 |
310 | cy.resolveEntranceAnimation();
311 |
312 | cy.findByRole('alert').move(delta);
313 | cy.get('@closeToast').should('have.been.called');
314 | });
315 | }
316 |
317 | for (const { axis, delta } of [
318 | { axis: 'x', delta: { deltaX: -100 } },
319 | { axis: 'y', delta: { deltaY: 40 } }
320 | ]) {
321 | it(`does not close toast when dragging on ${axis}-axis`, () => {
322 | cy.mount(
323 |
324 |
331 | hello
332 |
333 |
334 | );
335 |
336 | cy.resolveEntranceAnimation();
337 |
338 | cy.findByRole('alert').move(delta);
339 | cy.get('@closeToast').should('not.have.been.called');
340 | });
341 | }
342 | });
343 | });
344 |
--------------------------------------------------------------------------------
/src/components/Toast.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import React, { cloneElement, isValidElement } from 'react';
3 |
4 | import { useToast } from '../hooks/useToast';
5 | import { ToastProps } from '../types';
6 | import { Default, isFn, renderContent } from '../utils';
7 | import { CloseButton } from './CloseButton';
8 | import { ProgressBar } from './ProgressBar';
9 | import { getIcon } from './Icons';
10 |
11 | export const Toast: React.FC = props => {
12 | const { isRunning, preventExitTransition, toastRef, eventHandlers, playToast } = useToast(props);
13 | const {
14 | closeButton,
15 | children,
16 | autoClose,
17 | onClick,
18 | type,
19 | hideProgressBar,
20 | closeToast,
21 | transition: Transition,
22 | position,
23 | className,
24 | style,
25 | progressClassName,
26 | updateId,
27 | role,
28 | progress,
29 | rtl,
30 | toastId,
31 | deleteToast,
32 | isIn,
33 | isLoading,
34 | closeOnClick,
35 | theme,
36 | ariaLabel
37 | } = props;
38 | const defaultClassName = cx(
39 | `${Default.CSS_NAMESPACE}__toast`,
40 | `${Default.CSS_NAMESPACE}__toast-theme--${theme}`,
41 | `${Default.CSS_NAMESPACE}__toast--${type}`,
42 | {
43 | [`${Default.CSS_NAMESPACE}__toast--rtl`]: rtl
44 | },
45 | {
46 | [`${Default.CSS_NAMESPACE}__toast--close-on-click`]: closeOnClick
47 | }
48 | );
49 | const cssClasses = isFn(className)
50 | ? className({
51 | rtl,
52 | position,
53 | type,
54 | defaultClassName
55 | })
56 | : cx(defaultClassName, className);
57 | const icon = getIcon(props);
58 | const isProgressControlled = !!progress || !autoClose;
59 |
60 | const closeButtonProps = { closeToast, type, theme };
61 | let Close: React.ReactNode = null;
62 |
63 | if (closeButton === false) {
64 | // hide
65 | } else if (isFn(closeButton)) {
66 | Close = closeButton(closeButtonProps);
67 | } else if (isValidElement(closeButton)) {
68 | Close = cloneElement(closeButton, closeButtonProps);
69 | } else {
70 | Close = CloseButton(closeButtonProps);
71 | }
72 |
73 | return (
74 |
82 |
93 | {icon != null && (
94 |
99 | {icon}
100 |
101 | )}
102 | {renderContent(children, props, !isRunning)}
103 | {Close}
104 | {!props.customProgressBar && (
105 |
119 | )}
120 |
121 |
122 | );
123 | };
124 |
--------------------------------------------------------------------------------
/src/components/ToastContainer.tsx:
--------------------------------------------------------------------------------
1 | import cx from 'clsx';
2 | import React, { useEffect, useRef, useState } from 'react';
3 |
4 | import { toast } from '../core';
5 | import { useToastContainer } from '../hooks';
6 | import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect';
7 | import { ToastContainerProps, ToastPosition } from '../types';
8 | import { Default, Direction, isFn, parseClassName } from '../utils';
9 | import { Toast } from './Toast';
10 | import { Bounce } from './Transitions';
11 |
12 | export const defaultProps: ToastContainerProps = {
13 | position: 'top-right',
14 | transition: Bounce,
15 | autoClose: 5000,
16 | closeButton: true,
17 | pauseOnHover: true,
18 | pauseOnFocusLoss: true,
19 | draggable: 'touch',
20 | draggablePercent: Default.DRAGGABLE_PERCENT as number,
21 | draggableDirection: Direction.X,
22 | role: 'alert',
23 | theme: 'light',
24 | 'aria-label': 'Notifications Alt+T',
25 | hotKeys: e => e.altKey && e.code === 'KeyT'
26 | };
27 |
28 | export function ToastContainer(props: ToastContainerProps) {
29 | let containerProps: ToastContainerProps = {
30 | ...defaultProps,
31 | ...props
32 | };
33 | const stacked = props.stacked;
34 | const [collapsed, setIsCollapsed] = useState(true);
35 | const containerRef = useRef(null);
36 | const { getToastToRender, isToastActive, count } = useToastContainer(containerProps);
37 | const { className, style, rtl, containerId, hotKeys } = containerProps;
38 |
39 | function getClassName(position: ToastPosition) {
40 | const defaultClassName = cx(
41 | `${Default.CSS_NAMESPACE}__toast-container`,
42 | `${Default.CSS_NAMESPACE}__toast-container--${position}`,
43 | { [`${Default.CSS_NAMESPACE}__toast-container--rtl`]: rtl }
44 | );
45 | return isFn(className)
46 | ? className({
47 | position,
48 | rtl,
49 | defaultClassName
50 | })
51 | : cx(defaultClassName, parseClassName(className));
52 | }
53 |
54 | function collapseAll() {
55 | if (stacked) {
56 | setIsCollapsed(true);
57 | toast.play();
58 | }
59 | }
60 |
61 | useIsomorphicLayoutEffect(() => {
62 | if (stacked) {
63 | const nodes = containerRef.current!.querySelectorAll('[data-in="true"]');
64 | const gap = 12;
65 | const isTop = containerProps.position?.includes('top');
66 | let usedHeight = 0;
67 | let prevS = 0;
68 |
69 | Array.from(nodes)
70 | .reverse()
71 | .forEach((n, i) => {
72 | const node = n as HTMLElement;
73 | node.classList.add(`${Default.CSS_NAMESPACE}__toast--stacked`);
74 |
75 | if (i > 0) node.dataset.collapsed = `${collapsed}`;
76 |
77 | if (!node.dataset.pos) node.dataset.pos = isTop ? 'top' : 'bot';
78 |
79 | const y = usedHeight * (collapsed ? 0.2 : 1) + (collapsed ? 0 : gap * i);
80 |
81 | node.style.setProperty('--y', `${isTop ? y : y * -1}px`);
82 | node.style.setProperty('--g', `${gap}`);
83 | node.style.setProperty('--s', `${1 - (collapsed ? prevS : 0)}`);
84 |
85 | usedHeight += node.offsetHeight;
86 | prevS += 0.025;
87 | });
88 | }
89 | }, [collapsed, count, stacked]);
90 |
91 | useEffect(() => {
92 | function focusFirst(e: KeyboardEvent) {
93 | const node = containerRef.current;
94 | if (hotKeys(e)) {
95 | (node.querySelector('[tabIndex="0"]') as HTMLElement)?.focus();
96 | setIsCollapsed(false);
97 | toast.pause();
98 | }
99 | if (e.key === 'Escape' && (document.activeElement === node || node?.contains(document.activeElement))) {
100 | setIsCollapsed(true);
101 | toast.play();
102 | }
103 | }
104 |
105 | document.addEventListener('keydown', focusFirst);
106 |
107 | return () => {
108 | document.removeEventListener('keydown', focusFirst);
109 | };
110 | }, [hotKeys]);
111 |
112 | return (
113 | {
118 | if (stacked) {
119 | setIsCollapsed(false);
120 | toast.pause();
121 | }
122 | }}
123 | onMouseLeave={collapseAll}
124 | aria-live="polite"
125 | aria-atomic="false"
126 | aria-relevant="additions text"
127 | aria-label={containerProps['aria-label']}
128 | >
129 | {getToastToRender((position, toastList) => {
130 | const containerStyle: React.CSSProperties = !toastList.length
131 | ? { ...style, pointerEvents: 'none' }
132 | : { ...style };
133 |
134 | return (
135 |
142 | {toastList.map(({ content, props: toastProps }) => {
143 | return (
144 |
151 | {content}
152 |
153 | );
154 | })}
155 |
156 | );
157 | })}
158 |
159 | );
160 | }
161 |
--------------------------------------------------------------------------------
/src/components/Transitions.tsx:
--------------------------------------------------------------------------------
1 | import { cssTransition, Default } from '../utils';
2 |
3 | const getConfig = (animationName: string, appendPosition = false) => ({
4 | enter: `${Default.CSS_NAMESPACE}--animate ${Default.CSS_NAMESPACE}__${animationName}-enter`,
5 | exit: `${Default.CSS_NAMESPACE}--animate ${Default.CSS_NAMESPACE}__${animationName}-exit`,
6 | appendPosition
7 | });
8 |
9 | const Bounce = cssTransition(getConfig('bounce', true));
10 |
11 | const Slide = cssTransition(getConfig('slide', true));
12 |
13 | const Zoom = cssTransition(getConfig('zoom'));
14 |
15 | const Flip = cssTransition(getConfig('flip'));
16 |
17 | export { Bounce, Slide, Zoom, Flip };
18 |
--------------------------------------------------------------------------------
/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | export * from './CloseButton';
2 | export * from './ProgressBar';
3 | export { ToastContainer } from './ToastContainer';
4 | export * from './Transitions';
5 | export * from './Toast';
6 | export * from './Icons';
7 |
--------------------------------------------------------------------------------
/src/core/containerObserver.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Id,
3 | NotValidatedToastProps,
4 | OnChangeCallback,
5 | Toast,
6 | ToastContainerProps,
7 | ToastContent,
8 | ToastProps
9 | } from '../types';
10 | import { canBeRendered, getAutoCloseDelay, isNum, parseClassName, toToastItem } from '../utils';
11 |
12 | type Notify = () => void;
13 |
14 | export type ContainerObserver = ReturnType;
15 |
16 | export function createContainerObserver(
17 | id: Id,
18 | containerProps: ToastContainerProps,
19 | dispatchChanges: OnChangeCallback
20 | ) {
21 | let toastKey = 1;
22 | let toastCount = 0;
23 | let queue: Toast[] = [];
24 | let snapshot: Toast[] = [];
25 | let props = containerProps;
26 | const toasts = new Map();
27 | const listeners = new Set();
28 |
29 | const observe = (notify: Notify) => {
30 | listeners.add(notify);
31 | return () => listeners.delete(notify);
32 | };
33 |
34 | const notify = () => {
35 | snapshot = Array.from(toasts.values());
36 | listeners.forEach(cb => cb());
37 | };
38 |
39 | const shouldIgnoreToast = ({ containerId, toastId, updateId }: NotValidatedToastProps) => {
40 | const containerMismatch = containerId ? containerId !== id : id !== 1;
41 | const isDuplicate = toasts.has(toastId) && updateId == null;
42 |
43 | return containerMismatch || isDuplicate;
44 | };
45 |
46 | const toggle = (v: boolean, id?: Id) => {
47 | toasts.forEach(t => {
48 | if (id == null || id === t.props.toastId) t.toggle?.(v);
49 | });
50 | };
51 |
52 | const markAsRemoved = (v: Toast) => {
53 | v.props?.onClose?.(v.removalReason);
54 | v.isActive = false;
55 | };
56 |
57 | const removeToast = (id?: Id) => {
58 | if (id == null) {
59 | toasts.forEach(markAsRemoved);
60 | } else {
61 | const t = toasts.get(id);
62 | if (t) markAsRemoved(t);
63 | }
64 | notify();
65 | };
66 |
67 | const clearQueue = () => {
68 | toastCount -= queue.length;
69 | queue = [];
70 | };
71 |
72 | const addActiveToast = (toast: Toast) => {
73 | const { toastId, updateId } = toast.props;
74 | const isNew = updateId == null;
75 |
76 | if (toast.staleId) toasts.delete(toast.staleId);
77 | toast.isActive = true;
78 |
79 | toasts.set(toastId, toast);
80 | notify();
81 | dispatchChanges(toToastItem(toast, isNew ? 'added' : 'updated'));
82 |
83 | if (isNew) toast.props.onOpen?.();
84 | };
85 |
86 | const buildToast = (content: ToastContent, options: NotValidatedToastProps) => {
87 | if (shouldIgnoreToast(options)) return;
88 |
89 | const { toastId, updateId, data, staleId, delay } = options;
90 |
91 | const isNotAnUpdate = updateId == null;
92 |
93 | if (isNotAnUpdate) toastCount++;
94 |
95 | const toastProps = {
96 | ...props,
97 | style: props.toastStyle,
98 | key: toastKey++,
99 | ...Object.fromEntries(Object.entries(options).filter(([_, v]) => v != null)),
100 | toastId,
101 | updateId,
102 | data,
103 | isIn: false,
104 | className: parseClassName(options.className || props.toastClassName),
105 | progressClassName: parseClassName(options.progressClassName || props.progressClassName),
106 | autoClose: options.isLoading ? false : getAutoCloseDelay(options.autoClose, props.autoClose),
107 | closeToast(reason?: true) {
108 | toasts.get(toastId)!.removalReason = reason;
109 | removeToast(toastId);
110 | },
111 | deleteToast() {
112 | const toastToRemove = toasts.get(toastId);
113 |
114 | if (toastToRemove == null) return;
115 |
116 | dispatchChanges(toToastItem(toastToRemove, 'removed'));
117 | toasts.delete(toastId);
118 |
119 | toastCount--;
120 | if (toastCount < 0) toastCount = 0;
121 |
122 | if (queue.length > 0) {
123 | addActiveToast(queue.shift());
124 | return;
125 | }
126 |
127 | notify();
128 | }
129 | } as ToastProps;
130 |
131 | toastProps.closeButton = props.closeButton;
132 |
133 | if (options.closeButton === false || canBeRendered(options.closeButton)) {
134 | toastProps.closeButton = options.closeButton;
135 | } else if (options.closeButton === true) {
136 | toastProps.closeButton = canBeRendered(props.closeButton) ? props.closeButton : true;
137 | }
138 |
139 | const activeToast = {
140 | content,
141 | props: toastProps,
142 | staleId
143 | } as Toast;
144 |
145 | // not handling limit + delay by design. Waiting for user feedback first
146 | if (props.limit && props.limit > 0 && toastCount > props.limit && isNotAnUpdate) {
147 | queue.push(activeToast);
148 | } else if (isNum(delay)) {
149 | setTimeout(() => {
150 | addActiveToast(activeToast);
151 | }, delay);
152 | } else {
153 | addActiveToast(activeToast);
154 | }
155 | };
156 |
157 | return {
158 | id,
159 | props,
160 | observe,
161 | toggle,
162 | removeToast,
163 | toasts,
164 | clearQueue,
165 | buildToast,
166 | setProps(p: ToastContainerProps) {
167 | props = p;
168 | },
169 | setToggle: (id: Id, fn: (v: boolean) => void) => {
170 | const t = toasts.get(id);
171 | if (t) t.toggle = fn;
172 | },
173 | isToastActive: (id: Id) => toasts.get(id)?.isActive,
174 | getSnapshot: () => snapshot
175 | };
176 | }
177 |
--------------------------------------------------------------------------------
/src/core/genToastId.ts:
--------------------------------------------------------------------------------
1 | let TOAST_ID = 1;
2 |
3 | export const genToastId = () => `${TOAST_ID++}`;
4 |
--------------------------------------------------------------------------------
/src/core/index.ts:
--------------------------------------------------------------------------------
1 | export * from './toast';
2 |
--------------------------------------------------------------------------------
/src/core/store.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ClearWaitingQueueParams,
3 | Id,
4 | NotValidatedToastProps,
5 | OnChangeCallback,
6 | ToastContainerProps,
7 | ToastContent,
8 | ToastItem,
9 | ToastOptions
10 | } from '../types';
11 | import { Default, canBeRendered, isId } from '../utils';
12 | import { ContainerObserver, createContainerObserver } from './containerObserver';
13 |
14 | interface EnqueuedToast {
15 | content: ToastContent;
16 | options: NotValidatedToastProps;
17 | }
18 |
19 | interface RemoveParams {
20 | id?: Id;
21 | containerId: Id;
22 | }
23 |
24 | const containers = new Map();
25 | let renderQueue: EnqueuedToast[] = [];
26 | const listeners = new Set();
27 |
28 | const dispatchChanges = (data: ToastItem) => listeners.forEach(cb => cb(data));
29 |
30 | const hasContainers = () => containers.size > 0;
31 |
32 | function flushRenderQueue() {
33 | renderQueue.forEach(v => pushToast(v.content, v.options));
34 | renderQueue = [];
35 | }
36 |
37 | export const getToast = (id: Id, { containerId }: ToastOptions) =>
38 | containers.get(containerId || Default.CONTAINER_ID)?.toasts.get(id);
39 |
40 | export function isToastActive(id: Id, containerId?: Id) {
41 | if (containerId) return !!containers.get(containerId)?.isToastActive(id);
42 |
43 | let isActive = false;
44 | containers.forEach(c => {
45 | if (c.isToastActive(id)) isActive = true;
46 | });
47 |
48 | return isActive;
49 | }
50 |
51 | export function removeToast(params?: Id | RemoveParams) {
52 | if (!hasContainers()) {
53 | renderQueue = renderQueue.filter(v => params != null && v.options.toastId !== params);
54 | return;
55 | }
56 |
57 | if (params == null || isId(params)) {
58 | containers.forEach(c => {
59 | c.removeToast(params as Id);
60 | });
61 | } else if (params && ('containerId' in params || 'id' in params)) {
62 | const container = containers.get(params.containerId);
63 | container
64 | ? container.removeToast(params.id)
65 | : containers.forEach(c => {
66 | c.removeToast(params.id);
67 | });
68 | }
69 | }
70 |
71 | export const clearWaitingQueue = (p: ClearWaitingQueueParams = {}) => {
72 | containers.forEach(c => {
73 | if (c.props.limit && (!p.containerId || c.id === p.containerId)) {
74 | c.clearQueue();
75 | }
76 | });
77 | };
78 |
79 | export function pushToast(content: ToastContent, options: NotValidatedToastProps) {
80 | if (!canBeRendered(content)) return;
81 | if (!hasContainers()) renderQueue.push({ content, options });
82 |
83 | containers.forEach(c => {
84 | c.buildToast(content, options);
85 | });
86 | }
87 |
88 | interface ToggleToastParams {
89 | id?: Id;
90 | containerId?: Id;
91 | }
92 |
93 | type RegisterToggleOpts = {
94 | id: Id;
95 | containerId?: Id;
96 | fn: (v: boolean) => void;
97 | };
98 |
99 | export function registerToggle(opts: RegisterToggleOpts) {
100 | containers.get(opts.containerId || Default.CONTAINER_ID)?.setToggle(opts.id, opts.fn);
101 | }
102 |
103 | export function toggleToast(v: boolean, opt?: ToggleToastParams) {
104 | containers.forEach(c => {
105 | if (opt == null || !opt?.containerId) {
106 | c.toggle(v, opt?.id);
107 | } else if (opt?.containerId === c.id) {
108 | c.toggle(v, opt?.id);
109 | }
110 | });
111 | }
112 |
113 | export function registerContainer(props: ToastContainerProps) {
114 | const id = props.containerId || Default.CONTAINER_ID;
115 | return {
116 | subscribe(notify: () => void) {
117 | const container = createContainerObserver(id, props, dispatchChanges);
118 |
119 | containers.set(id, container);
120 | const unobserve = container.observe(notify);
121 | flushRenderQueue();
122 |
123 | return () => {
124 | unobserve();
125 | containers.delete(id);
126 | };
127 | },
128 | setProps(p: ToastContainerProps) {
129 | containers.get(id)?.setProps(p);
130 | },
131 | getSnapshot() {
132 | return containers.get(id)?.getSnapshot();
133 | }
134 | };
135 | }
136 |
137 | export function onChange(cb: OnChangeCallback) {
138 | listeners.add(cb);
139 |
140 | return () => {
141 | listeners.delete(cb);
142 | };
143 | }
144 |
--------------------------------------------------------------------------------
/src/core/toast.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ToastContainer } from '../components';
3 | import { toast } from './toast';
4 |
5 | beforeEach(() => {
6 | cy.viewport('macbook-15');
7 | });
8 |
9 | describe('without container', () => {
10 | it('enqueue toasts till container is mounted', () => {
11 | toast('msg1');
12 | toast('msg2');
13 |
14 | cy.findByText('msg1').should('not.exist');
15 | cy.findByText('msg2').should('not.exist');
16 |
17 | cy.mount( );
18 |
19 | cy.resolveEntranceAnimation();
20 | cy.findByText('msg1').should('exist');
21 | cy.findByText('msg2').should('exist');
22 | });
23 |
24 | it('remove toast from render queue', () => {
25 | toast('msg1');
26 | const id = toast('msg2');
27 | toast.dismiss(id);
28 |
29 | cy.mount( );
30 | cy.resolveEntranceAnimation();
31 |
32 | cy.findByText('msg1').should('exist');
33 | cy.findByText('msg2').should('not.exist');
34 | });
35 | });
36 |
37 | describe('with container', () => {
38 | beforeEach(() => {
39 | cy.mount(
40 | <>
41 |
42 | toast('msg')}>display msg
43 | >
44 | );
45 | });
46 |
47 | it('render toast', () => {
48 | cy.mount(
49 | <>
50 |
51 | toast('msg')}>display msg
52 | >
53 | );
54 | cy.findByRole('button').click();
55 | cy.findByText('msg').should('exist');
56 | });
57 |
58 | it('return a new id each time a notification is pushed', () => {
59 | const firstId = toast('Hello');
60 | const secondId = toast('Hello');
61 |
62 | expect(firstId).not.to.be.eq(secondId);
63 | });
64 |
65 | it('use the provided toastId from options', () => {
66 | const toastId = 11;
67 | const id = toast('Hello', { toastId });
68 |
69 | expect(id).to.be.eq(toastId);
70 | });
71 |
72 | it('handle change event', () => {
73 | toast.onChange(cy.stub().as('onChange'));
74 | const id = 'qq';
75 |
76 | cy.mount(
77 | <>
78 | {
80 | toast('msg', { data: 'xxxx', toastId: id });
81 | }}
82 | >
83 | display msg
84 |
85 | {
87 | toast.update(id, {
88 | render: 'world'
89 | });
90 | }}
91 | >
92 | update
93 |
94 | toast.dismiss(id)}>remove
95 |
96 | >
97 | );
98 |
99 | cy.findByRole('button', { name: 'display msg' }).click();
100 |
101 | cy.get('@onChange').should('have.been.calledWithMatch', {
102 | status: 'added',
103 | content: 'msg',
104 | data: 'xxxx'
105 | });
106 |
107 | cy.findByRole('button', { name: 'update' }).click();
108 |
109 | cy.get('@onChange').should('have.been.calledWithMatch', {
110 | status: 'updated',
111 | content: 'world'
112 | });
113 |
114 | // cy.wait(1000);
115 |
116 | // cy.findByRole('button', { name: 'remove' }).click();
117 | //
118 | // cy.get('@onChange').should('have.been.calledWithMatch', {
119 | // status: 'removed'
120 | // });
121 | });
122 |
123 | it('unsubscribe from change event', () => {
124 | const unsub = toast.onChange(cy.stub().as('onChange'));
125 | unsub();
126 | cy.findByRole('button').click();
127 | cy.get('@onChange').should('not.have.been.called');
128 | });
129 |
130 | describe('sa', () => {
131 | // it('be able remove toast programmatically', () => {
132 | // const id = 'test';
133 | //
134 | // cy.mount(
135 | // <>
136 | // {
138 | // toast('msg', { toastId: id });
139 | // }}
140 | // >
141 | // display msg
142 | //
143 | // toast.dismiss(id)}>remove
144 | //
145 | // >
146 | // );
147 | //
148 | // cy.findByRole('button', { name: 'display msg' }).click();
149 | // cy.findByText('msg').should('exist');
150 | //
151 | // cy.findByRole('button', { name: 'remove' }).click();
152 | // cy.resolveEntranceAnimation();
153 | // cy.findByText('msg').should('not.exist');
154 | // });
155 |
156 | it('pause and resume notification', () => {
157 | const id = toast('msg', {
158 | autoClose: 10000
159 | });
160 |
161 | cy.findByRole('progressbar').as('progressBar');
162 |
163 | cy.get('@progressBar')
164 | .should('have.attr', 'style')
165 | .and('include', 'animation-play-state: running')
166 | .then(() => {
167 | toast.pause({ id });
168 | cy.get('@progressBar')
169 | .should('have.attr', 'style')
170 | .and('include', 'animation-play-state: paused')
171 | .then(() => {
172 | toast.play({ id });
173 |
174 | cy.get('@progressBar').should('have.attr', 'style').and('include', 'animation-play-state: running');
175 | });
176 | });
177 | });
178 | });
179 |
180 | describe('update function', () => {
181 | it('update an existing toast', () => {
182 | const id = toast('msg');
183 |
184 | cy.resolveEntranceAnimation();
185 | cy.findByText('msg')
186 | .should('exist')
187 | .then(() => {
188 | toast.update(id, {
189 | render: 'foobar'
190 | });
191 |
192 | cy.findByText('msg').should('not.exist');
193 | cy.findByText('foobar').should('exist');
194 | })
195 | .then(() => {
196 | toast.update(id, {
197 | render: 'bazbar'
198 | });
199 | cy.findByText('foobar').should('not.exist');
200 | cy.findByText('bazbar').should('exist');
201 | });
202 | });
203 |
204 | it('keep the same content', () => {
205 | const id = toast('msg');
206 |
207 | cy.resolveEntranceAnimation();
208 | cy.findByText('msg').should('exist');
209 | cy.get('.myClass')
210 | .should('not.exist')
211 | .then(() => {
212 | toast.update(id, {
213 | className: 'myClass'
214 | });
215 |
216 | cy.get('.myClass').should('exist');
217 | cy.findByText('msg').should('exist');
218 | });
219 | });
220 |
221 | it('update a toast only when it exists', () => {
222 | toast.update(0, {
223 | render: 'msg'
224 | });
225 |
226 | cy.resolveEntranceAnimation();
227 | cy.findByText('msg').should('not.exist');
228 | });
229 |
230 | it('update the toastId', () => {
231 | const id = toast('msg');
232 | const nextId = 123;
233 |
234 | cy.resolveEntranceAnimation();
235 |
236 | cy.findByText('msg')
237 | .should('exist')
238 | .then(() => {
239 | expect(toast.isActive(id)).to.be.true;
240 | toast.update(id, {
241 | render: 'foobar',
242 | toastId: nextId
243 | });
244 | });
245 |
246 | cy.findByText('foobar')
247 | .should('exist')
248 | .then(() => {
249 | expect(toast.isActive(id)).to.be.false;
250 | expect(toast.isActive(nextId)).to.be.true;
251 | });
252 | });
253 | });
254 |
255 | it('can append classNames', () => {
256 | toast('msg', {
257 | className: 'class1',
258 | progressClassName: 'class3'
259 | });
260 |
261 | cy.get('.class1').should('exist');
262 | cy.get('.class3').should('exist');
263 | });
264 |
265 | it('uses syntactic sugar for different notification type', () => {
266 | toast('default');
267 | toast.success('success');
268 | toast.error('error');
269 | toast.warning('warning');
270 | toast.info('info');
271 | toast.warn('warn');
272 | toast.dark('dark');
273 |
274 | cy.resolveEntranceAnimation();
275 |
276 | cy.findByText('default').should('exist');
277 | cy.findByText('success').should('exist');
278 | cy.findByText('error').should('exist');
279 | cy.findByText('warning').should('exist');
280 | cy.findByText('info').should('exist');
281 | cy.findByText('warn').should('exist');
282 | cy.findByText('dark').should('exist');
283 | });
284 |
285 | it('handle controlled progress bar', () => {
286 | const id = toast('msg', {
287 | progress: 0.3
288 | });
289 |
290 | cy.resolveEntranceAnimation();
291 | cy.findByRole('progressbar')
292 | .should('have.attr', 'style')
293 | .and('include', 'scaleX(0.3)')
294 | .then(() => {
295 | toast.done(id);
296 | cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'scaleX(1)');
297 | });
298 | });
299 |
300 | it('handle rejected promise', () => {
301 | function rejectPromise() {
302 | return new Promise((_, reject) => {
303 | setTimeout(() => {
304 | reject(new Error('oops'));
305 | }, 2000);
306 | });
307 | }
308 |
309 | toast.promise(rejectPromise, {
310 | pending: 'loading',
311 | error: {
312 | render(props) {
313 | return <>{props.data?.message}>;
314 | }
315 | }
316 | });
317 |
318 | cy.resolveEntranceAnimation();
319 | cy.findByText('loading').should('exist');
320 |
321 | cy.wait(2000);
322 | cy.findByText('loading').should('not.exist');
323 | cy.findByText('oops').should('exist');
324 | });
325 |
326 | it('handle resolved promise', () => {
327 | function resolvePromise() {
328 | return new Promise((resolve, _) => {
329 | setTimeout(() => {
330 | resolve('it worked');
331 | }, 2000);
332 | });
333 | }
334 |
335 | toast.promise(resolvePromise, {
336 | pending: 'loading',
337 | success: {
338 | render(props) {
339 | return <>{props.data}>;
340 | }
341 | }
342 | });
343 |
344 | cy.resolveEntranceAnimation();
345 | cy.findByText('loading').should('exist');
346 |
347 | cy.wait(2000);
348 | cy.findByText('loading').should('not.exist');
349 | cy.findByText('it worked').should('exist');
350 | });
351 |
352 | it('support onOpen and onClose callback', () => {
353 | const id = 'hello';
354 |
355 | cy.mount(
356 | <>
357 | {
359 | toast('msg', {
360 | toastId: id,
361 | onOpen: cy.stub().as('onOpen'),
362 | onClose: cy.stub().as('onClose')
363 | });
364 | }}
365 | >
366 | display msg
367 |
368 | toast.dismiss(id)}>remove
369 |
370 | >
371 | );
372 |
373 | cy.findByRole('button', { name: 'display msg' }).click();
374 | cy.get('@onOpen').should('have.been.calledOnce');
375 |
376 | cy.findByRole('button', { name: 'remove' }).click();
377 | cy.get('@onClose').should('have.been.calledOnce');
378 | });
379 |
380 | xit('remove all toasts', () => {
381 | cy.mount(
382 | <>
383 | {
385 | toast('msg1');
386 | // toast('msg2');
387 | }}
388 | >
389 | display msg
390 |
391 | {
393 | toast.dismiss();
394 | }}
395 | >
396 | remove
397 |
398 |
399 | >
400 | );
401 |
402 | cy.findByRole('button', { name: 'display msg' }).click();
403 | cy.findByText('msg1').should('exist');
404 |
405 | cy.findByRole('button', { name: 'remove' }).click();
406 | cy.wait(2000);
407 | cy.findByText('msg1').should('not.exist');
408 | });
409 | });
410 |
411 | describe.skip('with multi containers', () => {
412 | const Containers = {
413 | First: 'first',
414 | Second: 'second',
415 | Third: 'third'
416 | };
417 |
418 | it('clear waiting queue for a given container', () => {
419 | cy.mount(
420 | <>
421 |
422 | {
424 | toast('msg1-c1', {
425 | containerId: Containers.First
426 | });
427 | toast('msg2-c1', {
428 | containerId: Containers.First
429 | });
430 | }}
431 | >
432 | first
433 |
434 | {
436 | toast('msg1-c2', {
437 | containerId: Containers.Second
438 | });
439 | toast('msg2-c2', {
440 | containerId: Containers.Second
441 | });
442 | }}
443 | >
444 | second
445 |
446 | {
448 | toast.clearWaitingQueue({ containerId: Containers.First });
449 | }}
450 | >
451 | clear
452 |
453 |
454 |
455 |
456 |
457 | >
458 | );
459 | cy.findByRole('button', { name: 'first' }).click();
460 | cy.findByRole('button', { name: 'second' }).click();
461 | cy.resolveEntranceAnimation();
462 |
463 | cy.findByText('msg2-c1').should('not.exist');
464 | cy.findByText('msg2-c2').should('not.exist');
465 |
466 | cy.findByText('msg1-c1').should('exist');
467 | cy.findByText('msg1-c2').should('exist');
468 |
469 | cy.findByText('msg1-c1').then(() => {
470 | cy.findByRole('button', { name: 'clear' }).click();
471 | cy.findByText('msg1-c1')
472 | .click()
473 | .then(() => {
474 | cy.resolveEntranceAnimation();
475 | cy.findByText('msg1-c1').should('not.exist');
476 | cy.findByText('msg2-c1').should('not.exist');
477 | });
478 | });
479 | });
480 |
481 | it('update a toast even when using multi containers', () => {
482 | const id = 'boo';
483 |
484 | cy.mount(
485 | <>
486 | {
488 | toast('second container', {
489 | toastId: id,
490 | containerId: Containers.Second
491 | });
492 | }}
493 | >
494 | notify
495 |
496 | {
498 | toast.update(id, {
499 | render: 'second container updated',
500 | containerId: Containers.Second
501 | });
502 | }}
503 | >
504 | update
505 |
506 |
507 | >
508 | );
509 | cy.findByRole('button', { name: 'notify' }).click();
510 | cy.resolveEntranceAnimation();
511 |
512 | cy.findByText('second container')
513 | .should('exist')
514 | .then(() => {
515 | cy.findByRole('button', { name: 'update' }).click();
516 | cy.findByText('second container updated').should('exist');
517 | });
518 | });
519 |
520 | xit('remove toast for a given container', () => {
521 | const toastId = '123';
522 |
523 | cy.mount(
524 | <>
525 |
526 | {
528 | toast('second container', {
529 | toastId,
530 | containerId: Containers.Second
531 | });
532 | }}
533 | >
534 | notify
535 |
536 | {
538 | toast.dismiss({
539 | containerId: Containers.Second,
540 | id: toastId
541 | });
542 | }}
543 | >
544 | clear
545 |
546 |
547 |
548 |
549 | >
550 | );
551 |
552 | cy.findByRole('button', { name: 'notify' }).click();
553 | cy.resolveEntranceAnimation();
554 |
555 | cy.findByText('second container')
556 | .should('exist')
557 | .then(() => {
558 | cy.findByRole('button', { name: 'clear' }).click();
559 |
560 | cy.findByText('second container').should('not.exist');
561 | });
562 | });
563 |
564 | xit('remove all toasts for a given container', () => {
565 | const toastId = '123';
566 |
567 | cy.mount(
568 | <>
569 |
570 | {
572 | toast('first container', {
573 | toastId,
574 | containerId: Containers.First
575 | });
576 | toast('third container', {
577 | toastId,
578 | containerId: Containers.Third
579 | });
580 | toast('third container second toast', {
581 | containerId: Containers.Third
582 | });
583 | }}
584 | >
585 | notify
586 |
587 | {
589 | toast.dismiss({
590 | containerId: Containers.Third
591 | });
592 | }}
593 | >
594 | clear third
595 |
596 | {
598 | toast.dismiss({ containerId: 'Non-Existing Container Id' });
599 | }}
600 | >
601 | clear non-existent
602 |
603 |
604 |
605 |
606 |
607 |
608 | >
609 | );
610 |
611 | cy.findByRole('button', { name: 'notify' }).click();
612 |
613 | cy.resolveEntranceAnimation();
614 |
615 | cy.findByText('first container').should('exist');
616 | cy.findByText('third container second toast').should('exist');
617 | cy.findByText('third container')
618 | .should('exist')
619 | .then(() => {
620 | cy.findByRole('button', { name: 'clear third' }).click();
621 | cy.resolveEntranceAnimation();
622 |
623 | cy.findByText('first container').should('exist');
624 | cy.findByText('third container').should('not.exist');
625 | cy.findByText('third container second toast').should('not.exist');
626 | cy.findByText('first container')
627 | .should('exist')
628 | .then(() => {
629 | cy.findByRole('button', { name: 'clear non-existent' }).click();
630 | cy.findByText('first container').should('not.exist');
631 | cy.findByText('third container').should('not.exist');
632 | });
633 | });
634 | });
635 |
636 | describe('with limit', () => {
637 | beforeEach(() => {
638 | cy.mount( );
639 | });
640 | it('limit the number of toast displayed', () => {
641 | toast('msg1');
642 | toast('msg2');
643 | toast('msg3');
644 | cy.resolveEntranceAnimation();
645 |
646 | cy.findByText('msg3').should('not.exist');
647 | cy.findByText('msg1').should('exist');
648 | cy.findByText('msg2')
649 | .should('exist')
650 | .click()
651 | .then(() => {
652 | cy.resolveEntranceAnimation();
653 | cy.findByText('msg3').should('exist');
654 | });
655 | });
656 |
657 | it('clear waiting queue', () => {
658 | toast('msg1');
659 | toast('msg2');
660 | toast('msg3');
661 | cy.resolveEntranceAnimation();
662 |
663 | cy.findByText('msg3').should('not.exist');
664 | cy.findByText('msg1').should('exist');
665 | cy.findByText('msg2')
666 | .should('exist')
667 | .then(() => {
668 | toast.clearWaitingQueue();
669 | cy.findByText('msg2')
670 | .click()
671 | .then(() => {
672 | cy.resolveEntranceAnimation();
673 | cy.findByText('msg3').should('not.exist');
674 | });
675 | });
676 | });
677 | });
678 | });
679 |
680 | describe('with stacked container', () => {
681 | it('render toasts', () => {
682 | cy.mount( );
683 | toast('hello 1');
684 | toast('hello 2');
685 | toast('hello 3');
686 |
687 | cy.findByText('hello 1').should('exist').and('not.be.visible');
688 | cy.findByText('hello 2').should('exist').and('not.be.visible');
689 | cy.findByText('hello 3').should('exist').and('be.visible');
690 | });
691 | });
692 |
--------------------------------------------------------------------------------
/src/core/toast.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ClearWaitingQueueFunc,
3 | Id,
4 | IdOpts,
5 | NotValidatedToastProps,
6 | OnChangeCallback,
7 | ToastContent,
8 | ToastOptions,
9 | ToastProps,
10 | TypeOptions,
11 | UpdateOptions
12 | } from '../types';
13 | import { isFn, isNum, isStr, Type } from '../utils';
14 | import { genToastId } from './genToastId';
15 | import { clearWaitingQueue, getToast, isToastActive, onChange, pushToast, removeToast, toggleToast } from './store';
16 |
17 | /**
18 | * Generate a toastId or use the one provided
19 | */
20 | function getToastId(options?: ToastOptions) {
21 | return options && (isStr(options.toastId) || isNum(options.toastId)) ? options.toastId : genToastId();
22 | }
23 |
24 | /**
25 | * If the container is not mounted, the toast is enqueued
26 | */
27 | function dispatchToast(content: ToastContent, options: NotValidatedToastProps): Id {
28 | pushToast(content, options);
29 | return options.toastId;
30 | }
31 |
32 | /**
33 | * Merge provided options with the defaults settings and generate the toastId
34 | */
35 | function mergeOptions(type: string, options?: ToastOptions) {
36 | return {
37 | ...options,
38 | type: (options && options.type) || type,
39 | toastId: getToastId(options)
40 | } as NotValidatedToastProps;
41 | }
42 |
43 | function createToastByType(type: string) {
44 | return (content: ToastContent, options?: ToastOptions) =>
45 | dispatchToast(content, mergeOptions(type, options));
46 | }
47 |
48 | function toast(content: ToastContent, options?: ToastOptions) {
49 | return dispatchToast(content, mergeOptions(Type.DEFAULT, options));
50 | }
51 |
52 | toast.loading = (content: ToastContent, options?: ToastOptions) =>
53 | dispatchToast(
54 | content,
55 | mergeOptions(Type.DEFAULT, {
56 | isLoading: true,
57 | autoClose: false,
58 | closeOnClick: false,
59 | closeButton: false,
60 | draggable: false,
61 | ...options
62 | })
63 | );
64 |
65 | export interface ToastPromiseParams {
66 | pending?: string | UpdateOptions;
67 | success?: string | UpdateOptions;
68 | error?: string | UpdateOptions;
69 | }
70 |
71 | function handlePromise(
72 | promise: Promise | (() => Promise),
73 | { pending, error, success }: ToastPromiseParams,
74 | options?: ToastOptions
75 | ) {
76 | let id: Id;
77 |
78 | if (pending) {
79 | id = isStr(pending)
80 | ? toast.loading(pending, options)
81 | : toast.loading(pending.render, {
82 | ...options,
83 | ...(pending as ToastOptions)
84 | } as ToastOptions);
85 | }
86 |
87 | const resetParams = {
88 | isLoading: null,
89 | autoClose: null,
90 | closeOnClick: null,
91 | closeButton: null,
92 | draggable: null
93 | };
94 |
95 | const resolver = (type: TypeOptions, input: string | UpdateOptions | undefined, result: T) => {
96 | // Remove the toast if the input has not been provided. This prevents the toast from hanging
97 | // in the pending state if a success/error toast has not been provided.
98 | if (input == null) {
99 | toast.dismiss(id);
100 | return;
101 | }
102 |
103 | const baseParams = {
104 | type,
105 | ...resetParams,
106 | ...options,
107 | data: result
108 | };
109 | const params = isStr(input) ? { render: input } : input;
110 |
111 | // if the id is set we know that it's an update
112 | if (id) {
113 | toast.update(id, {
114 | ...baseParams,
115 | ...params
116 | } as UpdateOptions);
117 | } else {
118 | // using toast.promise without loading
119 | toast(params!.render, {
120 | ...baseParams,
121 | ...params
122 | } as ToastOptions);
123 | }
124 |
125 | return result;
126 | };
127 |
128 | const p = isFn(promise) ? promise() : promise;
129 |
130 | //call the resolvers only when needed
131 | p.then(result => resolver('success', success, result)).catch(err => resolver('error', error, err));
132 |
133 | return p;
134 | }
135 |
136 | /**
137 | * Supply a promise or a function that return a promise and the notification will be updated if it resolves or fails.
138 | * When the promise is pending a spinner is displayed by default.
139 | * `toast.promise` returns the provided promise so you can chain it.
140 | *
141 | * Simple example:
142 | *
143 | * ```
144 | * toast.promise(MyPromise,
145 | * {
146 | * pending: 'Promise is pending',
147 | * success: 'Promise resolved 👌',
148 | * error: 'Promise rejected 🤯'
149 | * }
150 | * )
151 | *
152 | * ```
153 | *
154 | * Advanced usage:
155 | * ```
156 | * toast.promise<{name: string}, {message: string}, undefined>(
157 | * resolveWithSomeData,
158 | * {
159 | * pending: {
160 | * render: () => "I'm loading",
161 | * icon: false,
162 | * },
163 | * success: {
164 | * render: ({data}) => `Hello ${data.name}`,
165 | * icon: "🟢",
166 | * },
167 | * error: {
168 | * render({data}){
169 | * // When the promise reject, data will contains the error
170 | * return
171 | * }
172 | * }
173 | * }
174 | * )
175 | * ```
176 | */
177 | toast.promise = handlePromise;
178 | toast.success = createToastByType(Type.SUCCESS);
179 | toast.info = createToastByType(Type.INFO);
180 | toast.error = createToastByType(Type.ERROR);
181 | toast.warning = createToastByType(Type.WARNING);
182 | toast.warn = toast.warning;
183 | toast.dark = (content: ToastContent, options?: ToastOptions) =>
184 | dispatchToast(
185 | content,
186 | mergeOptions(Type.DEFAULT, {
187 | theme: 'dark',
188 | ...options
189 | })
190 | );
191 |
192 | interface RemoveParams {
193 | id?: Id;
194 | containerId: Id;
195 | }
196 |
197 | function dismiss(params: RemoveParams): void;
198 | function dismiss(params?: Id): void;
199 | function dismiss(params?: Id | RemoveParams) {
200 | removeToast(params);
201 | }
202 |
203 | /**
204 | * Remove toast programmatically
205 | *
206 | * - Remove all toasts:
207 | * ```
208 | * toast.dismiss()
209 | * ```
210 | *
211 | * - Remove all toasts that belongs to a given container
212 | * ```
213 | * toast.dismiss({ container: "123" })
214 | * ```
215 | *
216 | * - Remove toast that has a given id regardless the container
217 | * ```
218 | * toast.dismiss({ id: "123" })
219 | * ```
220 | *
221 | * - Remove toast that has a given id for a specific container
222 | * ```
223 | * toast.dismiss({ id: "123", containerId: "12" })
224 | * ```
225 | */
226 | toast.dismiss = dismiss;
227 |
228 | /**
229 | * Clear waiting queue when limit is used
230 | */
231 | toast.clearWaitingQueue = clearWaitingQueue as ClearWaitingQueueFunc;
232 |
233 | /**
234 | * Check if a toast is active
235 | *
236 | * - Check regardless the container
237 | * ```
238 | * toast.isActive("123")
239 | * ```
240 | *
241 | * - Check in a specific container
242 | * ```
243 | * toast.isActive("123", "containerId")
244 | * ```
245 | */
246 | toast.isActive = isToastActive;
247 |
248 | /**
249 | * Update a toast, see https://fkhadra.github.io/react-toastify/update-toast/ for more
250 | *
251 | * Example:
252 | * ```
253 | * // With a string
254 | * toast.update(toastId, {
255 | * render: "New content",
256 | * type: "info",
257 | * });
258 | *
259 | * // Or with a component
260 | * toast.update(toastId, {
261 | * render: MyComponent
262 | * });
263 | *
264 | * // Or a function
265 | * toast.update(toastId, {
266 | * render: () => New content
267 | * });
268 | *
269 | * // Apply a transition
270 | * toast.update(toastId, {
271 | * render: "New Content",
272 | * type: toast.TYPE.INFO,
273 | * transition: Rotate
274 | * })
275 | * ```
276 | */
277 | toast.update = (toastId: Id, options: UpdateOptions = {}) => {
278 | const toast = getToast(toastId, options as ToastOptions);
279 |
280 | if (toast) {
281 | const { props: oldOptions, content: oldContent } = toast;
282 |
283 | const nextOptions = {
284 | delay: 100,
285 | ...oldOptions,
286 | ...options,
287 | toastId: options.toastId || toastId,
288 | updateId: genToastId()
289 | } as ToastProps & UpdateOptions;
290 |
291 | if (nextOptions.toastId !== toastId) nextOptions.staleId = toastId;
292 |
293 | const content = nextOptions.render || oldContent;
294 | delete nextOptions.render;
295 |
296 | dispatchToast(content, nextOptions);
297 | }
298 | };
299 |
300 | /**
301 | * Used for controlled progress bar. It will automatically close the notification.
302 | *
303 | * If you don't want your notification to be clsoed when the timer is done you should use `toast.update` instead as follow instead:
304 | *
305 | * ```
306 | * toast.update(id, {
307 | * progress: null, // remove controlled progress bar
308 | * render: "ok",
309 | * type: "success",
310 | * autoClose: 5000 // set autoClose to the desired value
311 | * });
312 | * ```
313 | */
314 | toast.done = (id: Id) => {
315 | toast.update(id, {
316 | progress: 1
317 | });
318 | };
319 |
320 | /**
321 | * Subscribe to change when a toast is added, removed and updated
322 | *
323 | * Usage:
324 | * ```
325 | * const unsubscribe = toast.onChange((payload) => {
326 | * switch (payload.status) {
327 | * case "added":
328 | * // new toast added
329 | * break;
330 | * case "updated":
331 | * // toast updated
332 | * break;
333 | * case "removed":
334 | * // toast has been removed
335 | * break;
336 | * }
337 | * })
338 | * ```
339 | */
340 | toast.onChange = onChange as (cb: OnChangeCallback) => () => void;
341 |
342 | /**
343 | * Play a toast(s) timer progammatically
344 | *
345 | * Usage:
346 | *
347 | * - Play all toasts
348 | * ```
349 | * toast.play()
350 | * ```
351 | *
352 | * - Play all toasts for a given container
353 | * ```
354 | * toast.play({ containerId: "123" })
355 | * ```
356 | *
357 | * - Play toast that has a given id regardless the container
358 | * ```
359 | * toast.play({ id: "123" })
360 | * ```
361 | *
362 | * - Play toast that has a given id for a specific container
363 | * ```
364 | * toast.play({ id: "123", containerId: "12" })
365 | * ```
366 | */
367 | toast.play = (opts?: IdOpts) => toggleToast(true, opts);
368 |
369 | /**
370 | * Pause a toast(s) timer progammatically
371 | *
372 | * Usage:
373 | *
374 | * - Pause all toasts
375 | * ```
376 | * toast.pause()
377 | * ```
378 | *
379 | * - Pause all toasts for a given container
380 | * ```
381 | * toast.pause({ containerId: "123" })
382 | * ```
383 | *
384 | * - Pause toast that has a given id regardless the container
385 | * ```
386 | * toast.pause({ id: "123" })
387 | * ```
388 | *
389 | * - Pause toast that has a given id for a specific container
390 | * ```
391 | * toast.pause({ id: "123", containerId: "12" })
392 | * ```
393 | */
394 | toast.pause = (opts?: IdOpts) => toggleToast(false, opts);
395 |
396 | export { toast };
397 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useToastContainer';
2 | export * from './useToast';
3 |
--------------------------------------------------------------------------------
/src/hooks/useIsomorphicLayoutEffect.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useLayoutEffect } from 'react';
2 |
3 | export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
4 |
--------------------------------------------------------------------------------
/src/hooks/useToast.ts:
--------------------------------------------------------------------------------
1 | import { DOMAttributes, useEffect, useRef, useState } from 'react';
2 |
3 | import { ToastProps } from '../types';
4 | import { Default, Direction } from '../utils';
5 | import { registerToggle } from '../core/store';
6 |
7 | interface Draggable {
8 | start: number;
9 | delta: number;
10 | removalDistance: number;
11 | canCloseOnClick: boolean;
12 | canDrag: boolean;
13 | didMove: boolean;
14 | }
15 |
16 | export function useToast(props: ToastProps) {
17 | const [isRunning, setIsRunning] = useState(false);
18 | const [preventExitTransition, setPreventExitTransition] = useState(false);
19 | const toastRef = useRef(null);
20 | const drag = useRef({
21 | start: 0,
22 | delta: 0,
23 | removalDistance: 0,
24 | canCloseOnClick: true,
25 | canDrag: false,
26 | didMove: false
27 | }).current;
28 | const { autoClose, pauseOnHover, closeToast, onClick, closeOnClick } = props;
29 |
30 | registerToggle({
31 | id: props.toastId,
32 | containerId: props.containerId,
33 | fn: setIsRunning
34 | });
35 |
36 | useEffect(() => {
37 | if (props.pauseOnFocusLoss) {
38 | bindFocusEvents();
39 |
40 | return () => {
41 | unbindFocusEvents();
42 | };
43 | }
44 | }, [props.pauseOnFocusLoss]);
45 |
46 | function bindFocusEvents() {
47 | if (!document.hasFocus()) pauseToast();
48 |
49 | window.addEventListener('focus', playToast);
50 | window.addEventListener('blur', pauseToast);
51 | }
52 |
53 | function unbindFocusEvents() {
54 | window.removeEventListener('focus', playToast);
55 | window.removeEventListener('blur', pauseToast);
56 | }
57 |
58 | function onDragStart(e: React.PointerEvent) {
59 | if (props.draggable === true || props.draggable === e.pointerType) {
60 | bindDragEvents();
61 | const toast = toastRef.current!;
62 | drag.canCloseOnClick = true;
63 | drag.canDrag = true;
64 | toast.style.transition = 'none';
65 |
66 | if (props.draggableDirection === Direction.X) {
67 | drag.start = e.clientX;
68 | drag.removalDistance = toast.offsetWidth * (props.draggablePercent / 100);
69 | } else {
70 | drag.start = e.clientY;
71 | drag.removalDistance =
72 | (toast.offsetHeight *
73 | (props.draggablePercent === Default.DRAGGABLE_PERCENT
74 | ? props.draggablePercent * 1.5
75 | : props.draggablePercent)) /
76 | 100;
77 | }
78 | }
79 | }
80 |
81 | function onDragTransitionEnd(e: React.PointerEvent) {
82 | const { top, bottom, left, right } = toastRef.current!.getBoundingClientRect();
83 |
84 | if (
85 | e.nativeEvent.type !== 'touchend' &&
86 | props.pauseOnHover &&
87 | e.clientX >= left &&
88 | e.clientX <= right &&
89 | e.clientY >= top &&
90 | e.clientY <= bottom
91 | ) {
92 | pauseToast();
93 | } else {
94 | playToast();
95 | }
96 | }
97 |
98 | function playToast() {
99 | setIsRunning(true);
100 | }
101 |
102 | function pauseToast() {
103 | setIsRunning(false);
104 | }
105 |
106 | function bindDragEvents() {
107 | drag.didMove = false;
108 | document.addEventListener('pointermove', onDragMove);
109 | document.addEventListener('pointerup', onDragEnd);
110 | }
111 |
112 | function unbindDragEvents() {
113 | document.removeEventListener('pointermove', onDragMove);
114 | document.removeEventListener('pointerup', onDragEnd);
115 | }
116 |
117 | function onDragMove(e: PointerEvent) {
118 | const toast = toastRef.current!;
119 | if (drag.canDrag && toast) {
120 | drag.didMove = true;
121 | if (isRunning) pauseToast();
122 | if (props.draggableDirection === Direction.X) {
123 | drag.delta = e.clientX - drag.start;
124 | } else {
125 | drag.delta = e.clientY - drag.start;
126 | }
127 |
128 | // prevent false positive during a toast click
129 | if (drag.start !== e.clientX) drag.canCloseOnClick = false;
130 | const translate =
131 | props.draggableDirection === 'x' ? `${drag.delta}px, var(--y)` : `0, calc(${drag.delta}px + var(--y))`;
132 | toast.style.transform = `translate3d(${translate},0)`;
133 | toast.style.opacity = `${1 - Math.abs(drag.delta / drag.removalDistance)}`;
134 | }
135 | }
136 |
137 | function onDragEnd() {
138 | unbindDragEvents();
139 | const toast = toastRef.current!;
140 | if (drag.canDrag && drag.didMove && toast) {
141 | drag.canDrag = false;
142 | if (Math.abs(drag.delta) > drag.removalDistance) {
143 | setPreventExitTransition(true);
144 | props.closeToast(true);
145 | props.collapseAll();
146 | return;
147 | }
148 |
149 | toast.style.transition = 'transform 0.2s, opacity 0.2s';
150 | toast.style.removeProperty('transform');
151 | toast.style.removeProperty('opacity');
152 | }
153 | }
154 |
155 | const eventHandlers: DOMAttributes = {
156 | onPointerDown: onDragStart,
157 | onPointerUp: onDragTransitionEnd
158 | };
159 |
160 | if (autoClose && pauseOnHover) {
161 | eventHandlers.onMouseEnter = pauseToast;
162 |
163 | // progress control is delegated to the container
164 | if (!props.stacked) eventHandlers.onMouseLeave = playToast;
165 | }
166 |
167 | // prevent toast from closing when user drags the toast
168 | if (closeOnClick) {
169 | eventHandlers.onClick = (e: React.MouseEvent) => {
170 | onClick && onClick(e);
171 | drag.canCloseOnClick && closeToast(true);
172 | };
173 | }
174 |
175 | return {
176 | playToast,
177 | pauseToast,
178 | isRunning,
179 | preventExitTransition,
180 | toastRef,
181 | eventHandlers
182 | };
183 | }
184 |
--------------------------------------------------------------------------------
/src/hooks/useToastContainer.ts:
--------------------------------------------------------------------------------
1 | import { useRef, useSyncExternalStore } from 'react';
2 | import { isToastActive, registerContainer } from '../core/store';
3 | import { Toast, ToastContainerProps, ToastPosition } from '../types';
4 |
5 | export function useToastContainer(props: ToastContainerProps) {
6 | const { subscribe, getSnapshot, setProps } = useRef(registerContainer(props)).current;
7 | setProps(props);
8 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)?.slice();
9 |
10 | function getToastToRender(cb: (position: ToastPosition, toastList: Toast[]) => T) {
11 | if (!snapshot) return [];
12 |
13 | const toRender = new Map();
14 |
15 | if (props.newestOnTop) snapshot.reverse();
16 |
17 | snapshot.forEach(toast => {
18 | const { position } = toast.props;
19 | toRender.has(position) || toRender.set(position, []);
20 | toRender.get(position)!.push(toast);
21 | });
22 |
23 | return Array.from(toRender, p => cb(p[0], p[1]));
24 | }
25 |
26 | return {
27 | getToastToRender,
28 | isToastActive,
29 | count: snapshot?.length
30 | };
31 | }
32 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import './style.css';
2 |
3 | export { cssTransition, collapseToast } from './utils';
4 | export { ToastContainer, Bounce, Flip, Slide, Zoom, Icons } from './components';
5 | export type { IconProps, CloseButton } from './components';
6 | export type { ToastPromiseParams } from './core';
7 | export { toast } from './core';
8 | export type {
9 | TypeOptions,
10 | Theme,
11 | ToastPosition,
12 | ToastContentProps,
13 | ToastContent,
14 | ToastTransition,
15 | ToastClassName,
16 | ClearWaitingQueueParams,
17 | DraggableDirection,
18 | ToastOptions,
19 | UpdateOptions,
20 | ToastContainerProps,
21 | ToastTransitionProps,
22 | Id,
23 | ToastItem,
24 | ClearWaitingQueueFunc,
25 | OnChangeCallback,
26 | ToastIcon
27 | } from './types';
28 | export type { CloseButtonProps } from './components/CloseButton';
29 |
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --toastify-color-light: #fff;
3 | --toastify-color-dark: #121212;
4 | --toastify-color-info: #3498db;
5 | --toastify-color-success: #07bc0c;
6 | --toastify-color-warning: #f1c40f;
7 | --toastify-color-error: hsl(6, 78%, 57%);
8 | --toastify-color-transparent: rgba(255, 255, 255, 0.7);
9 |
10 | --toastify-icon-color-info: var(--toastify-color-info);
11 | --toastify-icon-color-success: var(--toastify-color-success);
12 | --toastify-icon-color-warning: var(--toastify-color-warning);
13 | --toastify-icon-color-error: var(--toastify-color-error);
14 |
15 | --toastify-container-width: fit-content;
16 | --toastify-toast-width: 320px;
17 | --toastify-toast-offset: 16px;
18 | --toastify-toast-top: max(var(--toastify-toast-offset), env(safe-area-inset-top));
19 | --toastify-toast-right: max(var(--toastify-toast-offset), env(safe-area-inset-right));
20 | --toastify-toast-left: max(var(--toastify-toast-offset), env(safe-area-inset-left));
21 | --toastify-toast-bottom: max(var(--toastify-toast-offset), env(safe-area-inset-bottom));
22 | --toastify-toast-background: #fff;
23 | --toastify-toast-padding: 14px;
24 | --toastify-toast-min-height: 64px;
25 | --toastify-toast-max-height: 800px;
26 | --toastify-toast-bd-radius: 6px;
27 | --toastify-toast-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
28 | --toastify-font-family: sans-serif;
29 | --toastify-z-index: 9999;
30 | --toastify-text-color-light: #757575;
31 | --toastify-text-color-dark: #fff;
32 |
33 | /* Used only for colored theme */
34 | --toastify-text-color-info: #fff;
35 | --toastify-text-color-success: #fff;
36 | --toastify-text-color-warning: #fff;
37 | --toastify-text-color-error: #fff;
38 |
39 | --toastify-spinner-color: #616161;
40 | --toastify-spinner-color-empty-area: #e0e0e0;
41 | --toastify-color-progress-light: linear-gradient(to right, #4cd964, #5ac8fa, #007aff, #34aadc, #5856d6, #ff2d55);
42 | --toastify-color-progress-dark: #bb86fc;
43 | --toastify-color-progress-info: var(--toastify-color-info);
44 | --toastify-color-progress-success: var(--toastify-color-success);
45 | --toastify-color-progress-warning: var(--toastify-color-warning);
46 | --toastify-color-progress-error: var(--toastify-color-error);
47 | /* used to control the opacity of the progress trail */
48 | --toastify-color-progress-bgo: 0.2;
49 | }
50 |
51 | .Toastify__toast-container {
52 | z-index: var(--toastify-z-index);
53 | -webkit-transform: translate3d(0, 0, var(--toastify-z-index));
54 | position: fixed;
55 | width: var(--toastify-container-width);
56 | box-sizing: border-box;
57 | color: #fff;
58 | display: flex;
59 | flex-direction: column;
60 | }
61 |
62 | .Toastify__toast-container--top-left {
63 | top: var(--toastify-toast-top);
64 | left: var(--toastify-toast-left);
65 | }
66 | .Toastify__toast-container--top-center {
67 | top: var(--toastify-toast-top);
68 | left: 50%;
69 | transform: translateX(-50%);
70 | align-items: center;
71 | }
72 | .Toastify__toast-container--top-right {
73 | top: var(--toastify-toast-top);
74 | right: var(--toastify-toast-right);
75 | align-items: end;
76 | }
77 | .Toastify__toast-container--bottom-left {
78 | bottom: var(--toastify-toast-bottom);
79 | left: var(--toastify-toast-left);
80 | }
81 | .Toastify__toast-container--bottom-center {
82 | bottom: var(--toastify-toast-bottom);
83 | left: 50%;
84 | transform: translateX(-50%);
85 | align-items: center;
86 | }
87 | .Toastify__toast-container--bottom-right {
88 | bottom: var(--toastify-toast-bottom);
89 | right: var(--toastify-toast-right);
90 | align-items: end;
91 | }
92 |
93 | .Toastify__toast {
94 | --y: 0;
95 | position: relative;
96 | touch-action: none;
97 | width: var(--toastify-toast-width);
98 | min-height: var(--toastify-toast-min-height);
99 | box-sizing: border-box;
100 | margin-bottom: 1rem;
101 | padding: var(--toastify-toast-padding);
102 | border-radius: var(--toastify-toast-bd-radius);
103 | box-shadow: var(--toastify-toast-shadow);
104 | max-height: var(--toastify-toast-max-height);
105 | font-family: var(--toastify-font-family);
106 | /* webkit only issue #791 */
107 | z-index: 0;
108 | /* inner swag */
109 | display: flex;
110 | flex: 1 auto;
111 | align-items: center;
112 | word-break: break-word;
113 | }
114 |
115 | @media only screen and (max-width: 480px) {
116 | .Toastify__toast-container {
117 | width: 100vw;
118 | left: env(safe-area-inset-left);
119 | margin: 0;
120 | }
121 | .Toastify__toast-container--top-left,
122 | .Toastify__toast-container--top-center,
123 | .Toastify__toast-container--top-right {
124 | top: env(safe-area-inset-top);
125 | transform: translateX(0);
126 | }
127 | .Toastify__toast-container--bottom-left,
128 | .Toastify__toast-container--bottom-center,
129 | .Toastify__toast-container--bottom-right {
130 | bottom: env(safe-area-inset-bottom);
131 | transform: translateX(0);
132 | }
133 | .Toastify__toast-container--rtl {
134 | right: env(safe-area-inset-right);
135 | left: initial;
136 | }
137 | .Toastify__toast {
138 | --toastify-toast-width: 100%;
139 | margin-bottom: 0;
140 | border-radius: 0;
141 | }
142 | }
143 |
144 | .Toastify__toast-container[data-stacked='true'] {
145 | width: var(--toastify-toast-width);
146 | }
147 |
148 | .Toastify__toast--stacked {
149 | position: absolute;
150 | width: 100%;
151 | transform: translate3d(0, var(--y), 0) scale(var(--s));
152 | transition: transform 0.3s;
153 | }
154 |
155 | .Toastify__toast--stacked[data-collapsed] .Toastify__toast-body,
156 | .Toastify__toast--stacked[data-collapsed] .Toastify__close-button {
157 | transition: opacity 0.1s;
158 | }
159 |
160 | .Toastify__toast--stacked[data-collapsed='false'] {
161 | overflow: visible;
162 | }
163 |
164 | .Toastify__toast--stacked[data-collapsed='true']:not(:last-child) > * {
165 | opacity: 0;
166 | }
167 |
168 | .Toastify__toast--stacked:after {
169 | content: '';
170 | position: absolute;
171 | left: 0;
172 | right: 0;
173 | height: calc(var(--g) * 1px);
174 | bottom: 100%;
175 | }
176 |
177 | .Toastify__toast--stacked[data-pos='top'] {
178 | top: 0;
179 | }
180 |
181 | .Toastify__toast--stacked[data-pos='bot'] {
182 | bottom: 0;
183 | }
184 |
185 | .Toastify__toast--stacked[data-pos='bot'].Toastify__toast--stacked:before {
186 | transform-origin: top;
187 | }
188 |
189 | .Toastify__toast--stacked[data-pos='top'].Toastify__toast--stacked:before {
190 | transform-origin: bottom;
191 | }
192 |
193 | .Toastify__toast--stacked:before {
194 | content: '';
195 | position: absolute;
196 | left: 0;
197 | right: 0;
198 | bottom: 0;
199 | height: 100%;
200 | transform: scaleY(3);
201 | z-index: -1;
202 | }
203 |
204 | .Toastify__toast--rtl {
205 | direction: rtl;
206 | }
207 |
208 | .Toastify__toast--close-on-click {
209 | cursor: pointer;
210 | }
211 |
212 | .Toastify__toast-icon {
213 | margin-inline-end: 10px;
214 | width: 22px;
215 | flex-shrink: 0;
216 | display: flex;
217 | }
218 |
219 | .Toastify--animate {
220 | animation-fill-mode: both;
221 | animation-duration: 0.5s;
222 | }
223 |
224 | .Toastify--animate-icon {
225 | animation-fill-mode: both;
226 | animation-duration: 0.3s;
227 | }
228 |
229 | .Toastify__toast-theme--dark {
230 | background: var(--toastify-color-dark);
231 | color: var(--toastify-text-color-dark);
232 | }
233 |
234 | .Toastify__toast-theme--light {
235 | background: var(--toastify-color-light);
236 | color: var(--toastify-text-color-light);
237 | }
238 |
239 | .Toastify__toast-theme--colored.Toastify__toast--default {
240 | background: var(--toastify-color-light);
241 | color: var(--toastify-text-color-light);
242 | }
243 |
244 | .Toastify__toast-theme--colored.Toastify__toast--info {
245 | color: var(--toastify-text-color-info);
246 | background: var(--toastify-color-info);
247 | }
248 |
249 | .Toastify__toast-theme--colored.Toastify__toast--success {
250 | color: var(--toastify-text-color-success);
251 | background: var(--toastify-color-success);
252 | }
253 |
254 | .Toastify__toast-theme--colored.Toastify__toast--warning {
255 | color: var(--toastify-text-color-warning);
256 | background: var(--toastify-color-warning);
257 | }
258 |
259 | .Toastify__toast-theme--colored.Toastify__toast--error {
260 | color: var(--toastify-text-color-error);
261 | background: var(--toastify-color-error);
262 | }
263 |
264 | .Toastify__progress-bar-theme--light {
265 | background: var(--toastify-color-progress-light);
266 | }
267 |
268 | .Toastify__progress-bar-theme--dark {
269 | background: var(--toastify-color-progress-dark);
270 | }
271 |
272 | .Toastify__progress-bar--info {
273 | background: var(--toastify-color-progress-info);
274 | }
275 |
276 | .Toastify__progress-bar--success {
277 | background: var(--toastify-color-progress-success);
278 | }
279 |
280 | .Toastify__progress-bar--warning {
281 | background: var(--toastify-color-progress-warning);
282 | }
283 |
284 | .Toastify__progress-bar--error {
285 | background: var(--toastify-color-progress-error);
286 | }
287 |
288 | .Toastify__progress-bar-theme--colored.Toastify__progress-bar--info,
289 | .Toastify__progress-bar-theme--colored.Toastify__progress-bar--success,
290 | .Toastify__progress-bar-theme--colored.Toastify__progress-bar--warning,
291 | .Toastify__progress-bar-theme--colored.Toastify__progress-bar--error {
292 | background: var(--toastify-color-transparent);
293 | }
294 |
295 | .Toastify__close-button {
296 | color: #fff;
297 | position: absolute;
298 | top: 6px;
299 | right: 6px;
300 | background: transparent;
301 | outline: none;
302 | border: none;
303 | padding: 0;
304 | cursor: pointer;
305 | opacity: 0.7;
306 | transition: 0.3s ease;
307 | z-index: 1;
308 | }
309 |
310 | .Toastify__toast--rtl .Toastify__close-button {
311 | left: 6px;
312 | right: unset;
313 | }
314 |
315 | .Toastify__close-button--light {
316 | color: #000;
317 | opacity: 0.3;
318 | }
319 |
320 | .Toastify__close-button > svg {
321 | fill: currentColor;
322 | height: 16px;
323 | width: 14px;
324 | }
325 |
326 | .Toastify__close-button:hover,
327 | .Toastify__close-button:focus {
328 | opacity: 1;
329 | }
330 |
331 | @keyframes Toastify__trackProgress {
332 | 0% {
333 | transform: scaleX(1);
334 | }
335 | 100% {
336 | transform: scaleX(0);
337 | }
338 | }
339 |
340 | .Toastify__progress-bar {
341 | position: absolute;
342 | bottom: 0;
343 | left: 0;
344 | width: 100%;
345 | height: 100%;
346 | z-index: 1;
347 | opacity: 0.7;
348 | transform-origin: left;
349 | }
350 |
351 | .Toastify__progress-bar--animated {
352 | animation: Toastify__trackProgress linear 1 forwards;
353 | }
354 |
355 | .Toastify__progress-bar--controlled {
356 | transition: transform 0.2s;
357 | }
358 |
359 | .Toastify__progress-bar--rtl {
360 | right: 0;
361 | left: initial;
362 | transform-origin: right;
363 | border-bottom-left-radius: initial;
364 | }
365 |
366 | .Toastify__progress-bar--wrp {
367 | position: absolute;
368 | overflow: hidden;
369 | bottom: 0;
370 | left: 0;
371 | width: 100%;
372 | height: 5px;
373 | border-bottom-left-radius: var(--toastify-toast-bd-radius);
374 | border-bottom-right-radius: var(--toastify-toast-bd-radius);
375 | }
376 |
377 | .Toastify__progress-bar--wrp[data-hidden='true'] {
378 | opacity: 0;
379 | }
380 |
381 | .Toastify__progress-bar--bg {
382 | opacity: var(--toastify-color-progress-bgo);
383 | width: 100%;
384 | height: 100%;
385 | }
386 |
387 | .Toastify__spinner {
388 | width: 20px;
389 | height: 20px;
390 | box-sizing: border-box;
391 | border: 2px solid;
392 | border-radius: 100%;
393 | border-color: var(--toastify-spinner-color-empty-area);
394 | border-right-color: var(--toastify-spinner-color);
395 | animation: Toastify__spin 0.65s linear infinite;
396 | }
397 |
398 | @keyframes Toastify__bounceInRight {
399 | from,
400 | 60%,
401 | 75%,
402 | 90%,
403 | to {
404 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
405 | }
406 | from {
407 | opacity: 0;
408 | transform: translate3d(3000px, 0, 0);
409 | }
410 | 60% {
411 | opacity: 1;
412 | transform: translate3d(-25px, 0, 0);
413 | }
414 | 75% {
415 | transform: translate3d(10px, 0, 0);
416 | }
417 | 90% {
418 | transform: translate3d(-5px, 0, 0);
419 | }
420 | to {
421 | transform: none;
422 | }
423 | }
424 |
425 | @keyframes Toastify__bounceOutRight {
426 | 20% {
427 | opacity: 1;
428 | transform: translate3d(-20px, var(--y), 0);
429 | }
430 | to {
431 | opacity: 0;
432 | transform: translate3d(2000px, var(--y), 0);
433 | }
434 | }
435 |
436 | @keyframes Toastify__bounceInLeft {
437 | from,
438 | 60%,
439 | 75%,
440 | 90%,
441 | to {
442 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
443 | }
444 | 0% {
445 | opacity: 0;
446 | transform: translate3d(-3000px, 0, 0);
447 | }
448 | 60% {
449 | opacity: 1;
450 | transform: translate3d(25px, 0, 0);
451 | }
452 | 75% {
453 | transform: translate3d(-10px, 0, 0);
454 | }
455 | 90% {
456 | transform: translate3d(5px, 0, 0);
457 | }
458 | to {
459 | transform: none;
460 | }
461 | }
462 |
463 | @keyframes Toastify__bounceOutLeft {
464 | 20% {
465 | opacity: 1;
466 | transform: translate3d(20px, var(--y), 0);
467 | }
468 | to {
469 | opacity: 0;
470 | transform: translate3d(-2000px, var(--y), 0);
471 | }
472 | }
473 |
474 | @keyframes Toastify__bounceInUp {
475 | from,
476 | 60%,
477 | 75%,
478 | 90%,
479 | to {
480 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
481 | }
482 | from {
483 | opacity: 0;
484 | transform: translate3d(0, 3000px, 0);
485 | }
486 | 60% {
487 | opacity: 1;
488 | transform: translate3d(0, -20px, 0);
489 | }
490 | 75% {
491 | transform: translate3d(0, 10px, 0);
492 | }
493 | 90% {
494 | transform: translate3d(0, -5px, 0);
495 | }
496 | to {
497 | transform: translate3d(0, 0, 0);
498 | }
499 | }
500 |
501 | @keyframes Toastify__bounceOutUp {
502 | 20% {
503 | transform: translate3d(0, calc(var(--y) - 10px), 0);
504 | }
505 | 40%,
506 | 45% {
507 | opacity: 1;
508 | transform: translate3d(0, calc(var(--y) + 20px), 0);
509 | }
510 | to {
511 | opacity: 0;
512 | transform: translate3d(0, -2000px, 0);
513 | }
514 | }
515 |
516 | @keyframes Toastify__bounceInDown {
517 | from,
518 | 60%,
519 | 75%,
520 | 90%,
521 | to {
522 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
523 | }
524 | 0% {
525 | opacity: 0;
526 | transform: translate3d(0, -3000px, 0);
527 | }
528 | 60% {
529 | opacity: 1;
530 | transform: translate3d(0, 25px, 0);
531 | }
532 | 75% {
533 | transform: translate3d(0, -10px, 0);
534 | }
535 | 90% {
536 | transform: translate3d(0, 5px, 0);
537 | }
538 | to {
539 | transform: none;
540 | }
541 | }
542 |
543 | @keyframes Toastify__bounceOutDown {
544 | 20% {
545 | transform: translate3d(0, calc(var(--y) - 10px), 0);
546 | }
547 | 40%,
548 | 45% {
549 | opacity: 1;
550 | transform: translate3d(0, calc(var(--y) + 20px), 0);
551 | }
552 | to {
553 | opacity: 0;
554 | transform: translate3d(0, 2000px, 0);
555 | }
556 | }
557 |
558 | .Toastify__bounce-enter--top-left,
559 | .Toastify__bounce-enter--bottom-left {
560 | animation-name: Toastify__bounceInLeft;
561 | }
562 |
563 | .Toastify__bounce-enter--top-right,
564 | .Toastify__bounce-enter--bottom-right {
565 | animation-name: Toastify__bounceInRight;
566 | }
567 |
568 | .Toastify__bounce-enter--top-center {
569 | animation-name: Toastify__bounceInDown;
570 | }
571 |
572 | .Toastify__bounce-enter--bottom-center {
573 | animation-name: Toastify__bounceInUp;
574 | }
575 |
576 | .Toastify__bounce-exit--top-left,
577 | .Toastify__bounce-exit--bottom-left {
578 | animation-name: Toastify__bounceOutLeft;
579 | }
580 |
581 | .Toastify__bounce-exit--top-right,
582 | .Toastify__bounce-exit--bottom-right {
583 | animation-name: Toastify__bounceOutRight;
584 | }
585 |
586 | .Toastify__bounce-exit--top-center {
587 | animation-name: Toastify__bounceOutUp;
588 | }
589 |
590 | .Toastify__bounce-exit--bottom-center {
591 | animation-name: Toastify__bounceOutDown;
592 | }
593 |
594 | @keyframes Toastify__zoomIn {
595 | from {
596 | opacity: 0;
597 | transform: scale3d(0.3, 0.3, 0.3);
598 | }
599 | 50% {
600 | opacity: 1;
601 | }
602 | }
603 |
604 | @keyframes Toastify__zoomOut {
605 | from {
606 | opacity: 1;
607 | }
608 | 50% {
609 | opacity: 0;
610 | transform: translate3d(0, var(--y), 0) scale3d(0.3, 0.3, 0.3);
611 | }
612 | to {
613 | opacity: 0;
614 | }
615 | }
616 |
617 | .Toastify__zoom-enter {
618 | animation-name: Toastify__zoomIn;
619 | }
620 |
621 | .Toastify__zoom-exit {
622 | animation-name: Toastify__zoomOut;
623 | }
624 |
625 | @keyframes Toastify__flipIn {
626 | from {
627 | transform: perspective(400px) rotate3d(1, 0, 0, 90deg);
628 | animation-timing-function: ease-in;
629 | opacity: 0;
630 | }
631 | 40% {
632 | transform: perspective(400px) rotate3d(1, 0, 0, -20deg);
633 | animation-timing-function: ease-in;
634 | }
635 | 60% {
636 | transform: perspective(400px) rotate3d(1, 0, 0, 10deg);
637 | opacity: 1;
638 | }
639 | 80% {
640 | transform: perspective(400px) rotate3d(1, 0, 0, -5deg);
641 | }
642 | to {
643 | transform: perspective(400px);
644 | }
645 | }
646 |
647 | @keyframes Toastify__flipOut {
648 | from {
649 | transform: translate3d(0, var(--y), 0) perspective(400px);
650 | }
651 | 30% {
652 | transform: translate3d(0, var(--y), 0) perspective(400px) rotate3d(1, 0, 0, -20deg);
653 | opacity: 1;
654 | }
655 | to {
656 | transform: translate3d(0, var(--y), 0) perspective(400px) rotate3d(1, 0, 0, 90deg);
657 | opacity: 0;
658 | }
659 | }
660 |
661 | .Toastify__flip-enter {
662 | animation-name: Toastify__flipIn;
663 | }
664 |
665 | .Toastify__flip-exit {
666 | animation-name: Toastify__flipOut;
667 | }
668 |
669 | @keyframes Toastify__slideInRight {
670 | from {
671 | transform: translate3d(110%, 0, 0);
672 | visibility: visible;
673 | }
674 | to {
675 | transform: translate3d(0, var(--y), 0);
676 | }
677 | }
678 |
679 | @keyframes Toastify__slideInLeft {
680 | from {
681 | transform: translate3d(-110%, 0, 0);
682 | visibility: visible;
683 | }
684 | to {
685 | transform: translate3d(0, var(--y), 0);
686 | }
687 | }
688 |
689 | @keyframes Toastify__slideInUp {
690 | from {
691 | transform: translate3d(0, 110%, 0);
692 | visibility: visible;
693 | }
694 | to {
695 | transform: translate3d(0, var(--y), 0);
696 | }
697 | }
698 |
699 | @keyframes Toastify__slideInDown {
700 | from {
701 | transform: translate3d(0, -110%, 0);
702 | visibility: visible;
703 | }
704 | to {
705 | transform: translate3d(0, var(--y), 0);
706 | }
707 | }
708 |
709 | @keyframes Toastify__slideOutRight {
710 | from {
711 | transform: translate3d(0, var(--y), 0);
712 | }
713 | to {
714 | visibility: hidden;
715 | transform: translate3d(110%, var(--y), 0);
716 | }
717 | }
718 |
719 | @keyframes Toastify__slideOutLeft {
720 | from {
721 | transform: translate3d(0, var(--y), 0);
722 | }
723 | to {
724 | visibility: hidden;
725 | transform: translate3d(-110%, var(--y), 0);
726 | }
727 | }
728 |
729 | @keyframes Toastify__slideOutDown {
730 | from {
731 | transform: translate3d(0, var(--y), 0);
732 | }
733 | to {
734 | visibility: hidden;
735 | transform: translate3d(0, 500px, 0);
736 | }
737 | }
738 |
739 | @keyframes Toastify__slideOutUp {
740 | from {
741 | transform: translate3d(0, var(--y), 0);
742 | }
743 | to {
744 | visibility: hidden;
745 | transform: translate3d(0, -500px, 0);
746 | }
747 | }
748 |
749 | .Toastify__slide-enter--top-left,
750 | .Toastify__slide-enter--bottom-left {
751 | animation-name: Toastify__slideInLeft;
752 | }
753 |
754 | .Toastify__slide-enter--top-right,
755 | .Toastify__slide-enter--bottom-right {
756 | animation-name: Toastify__slideInRight;
757 | }
758 |
759 | .Toastify__slide-enter--top-center {
760 | animation-name: Toastify__slideInDown;
761 | }
762 |
763 | .Toastify__slide-enter--bottom-center {
764 | animation-name: Toastify__slideInUp;
765 | }
766 |
767 | .Toastify__slide-exit--top-left,
768 | .Toastify__slide-exit--bottom-left {
769 | animation-name: Toastify__slideOutLeft;
770 | animation-timing-function: ease-in;
771 | animation-duration: 0.3s;
772 | }
773 |
774 | .Toastify__slide-exit--top-right,
775 | .Toastify__slide-exit--bottom-right {
776 | animation-name: Toastify__slideOutRight;
777 | animation-timing-function: ease-in;
778 | animation-duration: 0.3s;
779 | }
780 |
781 | .Toastify__slide-exit--top-center {
782 | animation-name: Toastify__slideOutUp;
783 | animation-timing-function: ease-in;
784 | animation-duration: 0.3s;
785 | }
786 |
787 | .Toastify__slide-exit--bottom-center {
788 | animation-name: Toastify__slideOutDown;
789 | animation-timing-function: ease-in;
790 | animation-duration: 0.3s;
791 | }
792 |
793 | @keyframes Toastify__spin {
794 | from {
795 | transform: rotate(0deg);
796 | }
797 | to {
798 | transform: rotate(360deg);
799 | }
800 | }
801 |
--------------------------------------------------------------------------------
/src/tests.cy.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ToastContainer } from './components';
3 | import { toast } from './core';
4 | import { ToastContentProps } from './types';
5 |
6 | it('allows to specify the reason when calling closeToast', () => {
7 | const onCloseFunc = cy.stub().as('onCloseFunc');
8 |
9 | function CustomNotification({ closeToast }: ToastContentProps) {
10 | return (
11 | {
13 | closeToast('foobar');
14 | }}
15 | >
16 | closeme
17 |
18 | );
19 | }
20 |
21 | cy.mount(
22 |
23 | {
25 | toast(CustomNotification, {
26 | onClose: onCloseFunc
27 | });
28 | }}
29 | >
30 | notify
31 |
32 |
33 |
34 | );
35 |
36 | cy.findByRole('button', { name: 'notify' }).click();
37 | cy.findByRole('alert').should('exist');
38 | cy.findByRole('button', { name: 'closeme' }).click();
39 |
40 | cy.get('@onCloseFunc').should('have.been.calledWith', 'foobar');
41 | });
42 |
43 | it('focus notification when alt+t is pressed', () => {
44 | cy.mount(
45 |
46 | {
48 | toast('hello', {
49 | ariaLabel: 'notification'
50 | });
51 | }}
52 | >
53 | notify
54 |
55 |
56 |
57 | );
58 |
59 | cy.findByRole('button', { name: 'notify' }).click();
60 | cy.resolveEntranceAnimation();
61 | cy.findByRole('alert').should('exist');
62 | cy.get('body').type('{alt+t}');
63 | cy.focused().should('have.attr', 'role', 'alert').and('have.attr', 'aria-label', 'notification');
64 | });
65 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import React, { HTMLAttributes } from 'react';
2 | import { CloseButtonProps, IconProps } from './components';
3 | import { clearWaitingQueue } from './core/store';
4 |
5 | type Nullable = {
6 | [P in keyof T]: T[P] | null;
7 | };
8 |
9 | export type TypeOptions = 'info' | 'success' | 'warning' | 'error' | 'default';
10 |
11 | export type Theme = 'light' | 'dark' | 'colored' | (string & {});
12 |
13 | export type ToastPosition = 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left';
14 |
15 | export type CloseToastFunc = ((reason?: boolean | string) => void) & ((e: React.MouseEvent) => void);
16 |
17 | export interface ToastContentProps {
18 | closeToast: CloseToastFunc;
19 | toastProps: ToastProps;
20 | isPaused: boolean;
21 | data: Data;
22 | }
23 |
24 | export type ToastContent = React.ReactNode | ((props: ToastContentProps) => React.ReactNode);
25 |
26 | export type ToastIcon = false | ((props: IconProps) => React.ReactNode) | React.ReactElement;
27 |
28 | export type Id = number | string;
29 |
30 | export type ToastTransition = React.FC | React.ComponentClass;
31 |
32 | /**
33 | * ClassName for the elements - can take a function to build a classname or a raw string that is cx'ed to defaults
34 | */
35 | export type ToastClassName =
36 | | ((context?: { type?: TypeOptions; defaultClassName?: string; position?: ToastPosition; rtl?: boolean }) => string)
37 | | string;
38 |
39 | export interface ClearWaitingQueueParams {
40 | containerId?: Id;
41 | }
42 |
43 | export type DraggableDirection = 'x' | 'y';
44 |
45 | interface CommonOptions {
46 | /**
47 | * Pause the timer when the mouse hover the toast.
48 | * `Default: true`
49 | */
50 | pauseOnHover?: boolean;
51 |
52 | /**
53 | * Pause the toast when the window loses focus.
54 | * `Default: true`
55 | */
56 | pauseOnFocusLoss?: boolean;
57 |
58 | /**
59 | * Remove the toast when clicked.
60 | * `Default: false`
61 | */
62 | closeOnClick?: boolean;
63 |
64 | /**
65 | * Set the delay in ms to close the toast automatically.
66 | * Use `false` to prevent the toast from closing.
67 | * `Default: 5000`
68 | */
69 | autoClose?: number | false;
70 |
71 | /**
72 | * Set the default position to use.
73 | * `One of: 'top-right', 'top-center', 'top-left', 'bottom-right', 'bottom-center', 'bottom-left'`
74 | * `Default: 'top-right'`
75 | */
76 | position?: ToastPosition;
77 |
78 | /**
79 | * Pass a custom close button.
80 | * To remove the close button pass `false`
81 | */
82 | closeButton?: boolean | ((props: CloseButtonProps) => React.ReactNode) | React.ReactElement;
83 |
84 | /**
85 | * An optional css class to set for the progress bar.
86 | */
87 | progressClassName?: ToastClassName;
88 |
89 | /**
90 | * Hide or show the progress bar.
91 | * `Default: false`
92 | */
93 | hideProgressBar?: boolean;
94 |
95 | /**
96 | * Pass a custom transition see https://fkhadra.github.io/react-toastify/custom-animation/
97 | */
98 | transition?: ToastTransition;
99 |
100 | /**
101 | * Allow toast to be draggable
102 | * `Default: 'touch'`
103 | */
104 | draggable?: boolean | 'mouse' | 'touch';
105 |
106 | /**
107 | * The percentage of the toast's width it takes for a drag to dismiss a toast
108 | * `Default: 80`
109 | */
110 | draggablePercent?: number;
111 |
112 | /**
113 | * Specify in which direction should you swipe to dismiss the toast
114 | * `Default: "x"`
115 | */
116 |
117 | draggableDirection?: DraggableDirection;
118 |
119 | /**
120 | * Define the ARIA role for the toast
121 | * `Default: alert`
122 | * https://www.w3.org/WAI/PF/aria/roles
123 | */
124 | role?: string;
125 |
126 | /**
127 | * Set id to handle multiple container
128 | */
129 | containerId?: Id;
130 |
131 | /**
132 | * Fired when clicking inside toaster
133 | */
134 | onClick?: (event: React.MouseEvent) => void;
135 |
136 | /**
137 | * Support right to left display.
138 | * `Default: false`
139 | */
140 | rtl?: boolean;
141 |
142 | /**
143 | * Used to display a custom icon. Set it to `false` to prevent
144 | * the icons from being displayed
145 | */
146 | icon?: ToastIcon;
147 |
148 | /**
149 | * Theme to use.
150 | * `One of: 'light', 'dark', 'colored'`
151 | * `Default: 'light'`
152 | */
153 | theme?: Theme;
154 |
155 | /**
156 | * When set to `true` the built-in progress bar won't be rendered at all. Autoclose delay won't have any effect as well
157 | * This is only used when you want to replace the progress bar with your own.
158 | *
159 | * See https://stackblitz.com/edit/react-toastify-custom-progress-bar?file=src%2FApp.tsx for an example.
160 | */
161 | customProgressBar?: boolean;
162 | }
163 |
164 | export interface ToastOptions extends CommonOptions {
165 | /**
166 | * An optional css class to set.
167 | */
168 | className?: ToastClassName;
169 |
170 | /**
171 | * Called when toast is mounted.
172 | */
173 | onOpen?: () => void;
174 |
175 | /**
176 | * Called when toast is unmounted.
177 | * The callback first argument is the closure reason.
178 | * It is "true" when the notification is closed by a user action like clicking on the close button.
179 | */
180 | onClose?: (reason?: boolean | string) => void;
181 |
182 | /**
183 | * An optional inline style to apply.
184 | */
185 | style?: React.CSSProperties;
186 |
187 | /**
188 | * Set the toast type.
189 | * `One of: 'info', 'success', 'warning', 'error', 'default'`
190 | */
191 | type?: TypeOptions;
192 |
193 | /**
194 | * Set a custom `toastId`
195 | */
196 | toastId?: Id;
197 |
198 | /**
199 | * Used during update
200 | */
201 | updateId?: Id;
202 |
203 | /**
204 | * Set the percentage for the controlled progress bar. `Value must be between 0 and 1.`
205 | */
206 | progress?: number;
207 |
208 | /**
209 | * Let you provide any data, useful when you are using your own component
210 | */
211 | data?: Data;
212 |
213 | /**
214 | * Let you specify the aria-label
215 | */
216 | ariaLabel?: string;
217 |
218 | /**
219 | * Add a delay in ms before the toast appear.
220 | */
221 | delay?: number;
222 |
223 | isLoading?: boolean;
224 | }
225 |
226 | export interface UpdateOptions extends Nullable> {
227 | /**
228 | * Used to update a toast.
229 | * Pass any valid ReactNode(string, number, component)
230 | */
231 | render?: ToastContent;
232 | }
233 |
234 | export interface ToastContainerProps extends CommonOptions, Pick, 'aria-label'> {
235 | /**
236 | * An optional css class to set.
237 | */
238 | className?: ToastClassName;
239 |
240 | /**
241 | * Will stack the toast with the newest on the top.
242 | */
243 | stacked?: boolean;
244 |
245 | /**
246 | * Whether or not to display the newest toast on top.
247 | * `Default: false`
248 | */
249 | newestOnTop?: boolean;
250 |
251 | /**
252 | * An optional inline style to apply.
253 | */
254 | style?: React.CSSProperties;
255 |
256 | /**
257 | * An optional inline style to apply for the toast.
258 | */
259 | toastStyle?: React.CSSProperties;
260 |
261 | /**
262 | * An optional css class for the toast.
263 | */
264 | toastClassName?: ToastClassName;
265 |
266 | /**
267 | * Limit the number of toast displayed at the same time
268 | */
269 | limit?: number;
270 |
271 | /**
272 | * Shortcut to focus the first notification with the keyboard
273 | * `default: Alt+t`
274 | *
275 | * ```
276 | * // focus when user presses ⌘ + F
277 | * const matchShortcut = (e: KeyboardEvent) => e.metaKey && e.key === 'f'
278 | * ```
279 | */
280 | hotKeys?: (e: KeyboardEvent) => boolean;
281 | }
282 |
283 | export interface ToastTransitionProps {
284 | isIn: boolean;
285 | done: () => void;
286 | position: ToastPosition | string;
287 | preventExitTransition: boolean;
288 | nodeRef: React.RefObject;
289 | children?: React.ReactNode;
290 | playToast(): void;
291 | }
292 |
293 | /**
294 | * @INTERNAL
295 | */
296 | export interface ToastProps extends ToastOptions {
297 | isIn: boolean;
298 | staleId?: Id;
299 | toastId: Id;
300 | key: Id;
301 | transition: ToastTransition;
302 | closeToast: CloseToastFunc;
303 | position: ToastPosition;
304 | children?: ToastContent;
305 | draggablePercent: number;
306 | draggableDirection?: DraggableDirection;
307 | progressClassName?: ToastClassName;
308 | className?: ToastClassName;
309 | deleteToast: () => void;
310 | theme: Theme;
311 | type: TypeOptions;
312 | collapseAll: () => void;
313 | stacked?: boolean;
314 | }
315 |
316 | /**
317 | * @INTERNAL
318 | */
319 | export interface NotValidatedToastProps extends Partial {
320 | toastId: Id;
321 | }
322 |
323 | /**
324 | * @INTERNAL
325 | */
326 | export interface Toast {
327 | content: ToastContent;
328 | props: ToastProps;
329 | toggle?: (v: boolean) => void;
330 | removalReason?: true | undefined;
331 | isActive: boolean;
332 | staleId?: Id;
333 | }
334 |
335 | export type ToastItemStatus = 'added' | 'removed' | 'updated';
336 |
337 | export interface ToastItem {
338 | content: ToastContent;
339 | id: Id;
340 | theme?: Theme;
341 | type?: TypeOptions;
342 | isLoading?: boolean;
343 | containerId?: Id;
344 | data: Data;
345 | icon?: ToastIcon;
346 | status: ToastItemStatus;
347 | reason?: boolean | string;
348 | }
349 |
350 | export type OnChangeCallback = (toast: ToastItem) => void;
351 |
352 | export type IdOpts = {
353 | id?: Id;
354 | containerId?: Id;
355 | };
356 |
357 | export type ClearWaitingQueueFunc = typeof clearWaitingQueue;
358 |
--------------------------------------------------------------------------------
/src/utils/collapseToast.ts:
--------------------------------------------------------------------------------
1 | import { Default } from './constant';
2 |
3 | /**
4 | * Used to collapse toast after exit animation
5 | */
6 | export function collapseToast(node: HTMLElement, done: () => void, duration = Default.COLLAPSE_DURATION) {
7 | const { scrollHeight, style } = node;
8 |
9 | requestAnimationFrame(() => {
10 | style.minHeight = 'initial';
11 | style.height = scrollHeight + 'px';
12 | style.transition = `all ${duration}ms`;
13 |
14 | requestAnimationFrame(() => {
15 | style.height = '0';
16 | style.padding = '0';
17 | style.margin = '0';
18 | setTimeout(done, duration as number);
19 | });
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/constant.ts:
--------------------------------------------------------------------------------
1 | export const enum Type {
2 | INFO = 'info',
3 | SUCCESS = 'success',
4 | WARNING = 'warning',
5 | ERROR = 'error',
6 | DEFAULT = 'default'
7 | }
8 |
9 | export const enum Default {
10 | COLLAPSE_DURATION = 300,
11 | DEBOUNCE_DURATION = 50,
12 | CSS_NAMESPACE = 'Toastify',
13 | DRAGGABLE_PERCENT = 80,
14 | CONTAINER_ID = 1
15 | }
16 |
17 | export const enum Direction {
18 | X = 'x',
19 | Y = 'y'
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/cssTransition.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useLayoutEffect, useRef } from 'react';
2 | import { collapseToast } from './collapseToast';
3 | import { Default } from './constant';
4 |
5 | import { ToastTransitionProps } from '../types';
6 |
7 | export interface CSSTransitionProps {
8 | /**
9 | * Css class to apply when toast enter
10 | */
11 | enter: string;
12 |
13 | /**
14 | * Css class to apply when toast leave
15 | */
16 | exit: string;
17 |
18 | /**
19 | * Append current toast position to the classname.
20 | * If multiple classes are provided, only the last one will get the position
21 | * For instance `myclass--top-center`...
22 | * `Default: false`
23 | */
24 | appendPosition?: boolean;
25 |
26 | /**
27 | * Collapse toast smoothly when exit animation end
28 | * `Default: true`
29 | */
30 | collapse?: boolean;
31 |
32 | /**
33 | * Collapse transition duration
34 | * `Default: 300`
35 | */
36 | collapseDuration?: number;
37 | }
38 |
39 | const enum AnimationStep {
40 | Enter,
41 | Exit
42 | }
43 |
44 | /**
45 | * Css animation that just work.
46 | * You could use animate.css for instance
47 | *
48 | *
49 | * ```
50 | * cssTransition({
51 | * enter: "animate__animated animate__bounceIn",
52 | * exit: "animate__animated animate__bounceOut"
53 | * })
54 | * ```
55 | *
56 | */
57 | export function cssTransition({
58 | enter,
59 | exit,
60 | appendPosition = false,
61 | collapse = true,
62 | collapseDuration = Default.COLLAPSE_DURATION
63 | }: CSSTransitionProps) {
64 | return function ToastTransition({
65 | children,
66 | position,
67 | preventExitTransition,
68 | done,
69 | nodeRef,
70 | isIn,
71 | playToast
72 | }: ToastTransitionProps) {
73 | const enterClassName = appendPosition ? `${enter}--${position}` : enter;
74 | const exitClassName = appendPosition ? `${exit}--${position}` : exit;
75 | const animationStep = useRef(AnimationStep.Enter);
76 |
77 | useLayoutEffect(() => {
78 | const node = nodeRef.current!;
79 | const classToToken = enterClassName.split(' ');
80 |
81 | const onEntered = (e: AnimationEvent) => {
82 | if (e.target !== nodeRef.current) return;
83 |
84 | playToast();
85 | node.removeEventListener('animationend', onEntered);
86 | node.removeEventListener('animationcancel', onEntered);
87 | if (animationStep.current === AnimationStep.Enter && e.type !== 'animationcancel') {
88 | node.classList.remove(...classToToken);
89 | }
90 | };
91 |
92 | const onEnter = () => {
93 | node.classList.add(...classToToken);
94 | node.addEventListener('animationend', onEntered);
95 | node.addEventListener('animationcancel', onEntered);
96 | };
97 |
98 | onEnter();
99 | }, []);
100 |
101 | useEffect(() => {
102 | const node = nodeRef.current!;
103 |
104 | const onExited = () => {
105 | node.removeEventListener('animationend', onExited);
106 | collapse ? collapseToast(node, done, collapseDuration) : done();
107 | };
108 |
109 | const onExit = () => {
110 | animationStep.current = AnimationStep.Exit;
111 | node.className += ` ${exitClassName}`;
112 | node.addEventListener('animationend', onExited);
113 | };
114 |
115 | if (!isIn) preventExitTransition ? onExited() : onExit();
116 | }, [isIn]);
117 |
118 | return <>{children}>;
119 | };
120 | }
121 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './propValidator';
2 | export * from './constant';
3 | export * from './cssTransition';
4 | export * from './collapseToast';
5 | export * from './mapper';
6 |
--------------------------------------------------------------------------------
/src/utils/mapper.ts:
--------------------------------------------------------------------------------
1 | import { Toast, ToastContentProps, ToastItem, ToastItemStatus, ToastProps } from '../types';
2 | import { cloneElement, isValidElement, ReactElement } from 'react';
3 | import { isFn, isStr } from './propValidator';
4 |
5 | export function toToastItem(toast: Toast, status: ToastItemStatus): ToastItem {
6 | return {
7 | content: renderContent(toast.content, toast.props),
8 | containerId: toast.props.containerId,
9 | id: toast.props.toastId,
10 | theme: toast.props.theme,
11 | type: toast.props.type,
12 | data: toast.props.data || {},
13 | isLoading: toast.props.isLoading,
14 | icon: toast.props.icon,
15 | reason: toast.removalReason,
16 | status
17 | };
18 | }
19 |
20 | export function renderContent(content: unknown, props: ToastProps, isPaused: boolean = false) {
21 | if (isValidElement(content) && !isStr(content.type)) {
22 | return cloneElement(content as ReactElement, {
23 | closeToast: props.closeToast,
24 | toastProps: props,
25 | data: props.data,
26 | isPaused
27 | });
28 | } else if (isFn(content)) {
29 | return content({
30 | closeToast: props.closeToast,
31 | toastProps: props,
32 | data: props.data,
33 | isPaused
34 | });
35 | }
36 |
37 | return content;
38 | }
39 |
--------------------------------------------------------------------------------
/src/utils/propValidator.ts:
--------------------------------------------------------------------------------
1 | import { isValidElement } from 'react';
2 | import { Id } from '../types';
3 |
4 | export const isNum = (v: any): v is Number => typeof v === 'number' && !isNaN(v);
5 |
6 | export const isStr = (v: any): v is String => typeof v === 'string';
7 |
8 | export const isFn = (v: any): v is Function => typeof v === 'function';
9 |
10 | export const isId = (v: unknown): v is Id => isStr(v) || isNum(v);
11 |
12 | export const parseClassName = (v: any) => (isStr(v) || isFn(v) ? v : null);
13 |
14 | export const getAutoCloseDelay = (toastAutoClose?: false | number, containerAutoClose?: false | number) =>
15 | toastAutoClose === false || (isNum(toastAutoClose) && toastAutoClose > 0) ? toastAutoClose : containerAutoClose;
16 |
17 | export const canBeRendered = (content: T): boolean =>
18 | isValidElement(content) || isStr(content) || isFn(content) || isNum(content);
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "jsx": "react",
4 | "moduleResolution": "node",
5 | "esModuleInterop": true,
6 | "lib": ["es2015", "dom"]
7 | }
8 | }
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, Options } from 'tsup';
2 |
3 | const injectFunc = `
4 | function injectStyle(css) {
5 | if (!css || typeof document === 'undefined') return
6 |
7 | const head = document.head || document.getElementsByTagName('head')[0]
8 | const style = document.createElement('style')
9 | style.type = 'text/css'
10 |
11 | if(head.firstChild) {
12 | head.insertBefore(style, head.firstChild)
13 | } else {
14 | head.appendChild(style)
15 | }
16 |
17 | if(style.styleSheet) {
18 | style.styleSheet.cssText = css
19 | } else {
20 | style.appendChild(document.createTextNode(css))
21 | }
22 | }
23 | `;
24 |
25 | const baseConfig: Options = {
26 | minify: true,
27 | target: 'es2018',
28 | sourcemap: true,
29 | dts: true,
30 | format: ['esm', 'cjs'],
31 | injectStyle: css => {
32 | return `${injectFunc}injectStyle(${css});`;
33 | },
34 | banner: {
35 | js: '"use client";'
36 | }
37 | };
38 |
39 | export default defineConfig([
40 | {
41 | ...baseConfig,
42 | entry: ['src/index.ts'],
43 | external: ['react'],
44 | clean: ['dist']
45 | },
46 | {
47 | ...baseConfig,
48 | injectStyle: false,
49 | entry: { unstyled: 'src/index.ts' },
50 | external: ['react'],
51 | clean: ['dist']
52 | },
53 | {
54 | ...baseConfig,
55 | entry: {
56 | 'use-notification-center/index': 'src/addons/use-notification-center/index.ts'
57 | },
58 | external: ['react', 'react-toastify'],
59 | clean: ['addons'],
60 | outDir: 'addons'
61 | }
62 | ]);
63 |
--------------------------------------------------------------------------------
/vite.config.mts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 | import istanbul from 'vite-plugin-istanbul';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [
8 | react(),
9 | istanbul({
10 | cypress: true,
11 | requireEnv: false
12 | })
13 | ]
14 | });
15 |
--------------------------------------------------------------------------------