├── .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 | [![Financial Contributors on Open Collective](https://opencollective.com/react-toastify/all/badge.svg?label=financial+contributors)](https://opencollective.com/react-toastify) ![React-toastify CI](https://github.com/fkhadra/react-toastify/workflows/React-toastify%20CI/badge.svg) 4 | ![npm](https://img.shields.io/npm/dm/react-toastify.svg?label=%E2%8F%ACdownloads&style=for-the-badge) 5 | ![npm](https://img.shields.io/npm/v/react-toastify.svg?style=for-the-badge) 6 | ![NPM](https://img.shields.io/npm/l/react-toastify.svg?label=%F0%9F%93%9Clicense&style=for-the-badge) 7 | ![Coveralls github](https://img.shields.io/coveralls/github/fkhadra/react-toastify.svg?label=%E2%9B%B1coverage&style=for-the-badge) 8 | 9 | 10 | ![React toastify](https://user-images.githubusercontent.com/5574267/130804494-a9d2d69c-f170-4576-b2e1-0bb7f13dd92d.gif "React toastify") 11 | 12 | ![stacked](https://github.com/fkhadra/react-toastify/assets/5574267/975c7c01-b95e-43cf-9100-256fa8ef2760) 13 | 14 | ![custom-style](https://github.com/user-attachments/assets/311672f7-f98a-46f3-a2ab-a9d1a05186a7) 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 | 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 |
      127 | 133 |
    134 |
    135 |
    136 |

    Type

    137 |
      138 | 139 |
    140 |
    141 |
    142 |

    Options

    143 |
    144 | 156 | 171 | 181 | 191 | 201 |
    202 |
      {this.renderFlags()}
    203 |
    204 |
    205 |
    206 | 210 | 211 |
    212 |
    213 |
      214 |
    • 215 | 221 |
    • 222 |
    • 223 | 226 |
    • 227 |
    • 228 | 231 |
    • 232 |
    • 233 | 239 |
    • 240 |
    • 241 | 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 | 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 |
    5 | 10 | 38 | 39 |

    Welcome to React-toastify

    40 |
    React notification made easy !
    41 | 58 |
    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 | 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 | 23 | 24 | 25 | 26 | 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 | 51 | 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 | 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 |
    119 |
    122 |
    130 |
    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 | 43 | 44 | ); 45 | }); 46 | 47 | it('render toast', () => { 48 | cy.mount( 49 | <> 50 | 51 | 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 | 85 | 94 | 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 | // 143 | // 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 | 368 | 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 | 391 | 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 | 434 | 446 | 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 | 496 | 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 | 536 | 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 | 587 | 596 | 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 | 18 | ); 19 | } 20 | 21 | cy.mount( 22 |
    23 | 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 | 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 | --------------------------------------------------------------------------------