Repository: fkhadra/react-toastify Branch: main Commit: e1fa4760cea8 Files: 71 Total size: 158.9 KB Directory structure: gitextract_tvelgcr9/ ├── .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/ │ └── support/ │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ └── style.css ├── cypress.config.ts ├── lefthook.yml ├── package.json ├── playground/ │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── 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 ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: fkhadra ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ **Do you want to request a _feature_ or report a _bug_?** **What is the current behavior?** **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:** **What is the expected behavior?** **Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?** ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ **Before submitting a pull request,** please make sure the following is done: 1. Fork [the repository](https://github.com/fkhadra/react-toastify) and create your branch from `main`. 2. Run `pnpm i` in the repository root. 3. If you've fixed a bug or added code that should be tested, add tests! 4. Ensure the test suite passes (`pnpm test`). 5. Run `pnpm start` to test your changes in the playground. 6. Update the readme is needed 7. Update the typescript definition is needed 8. Format your code with [prettier](https://github.com/prettier/prettier) (`pnpm prettier`). 9. Make sure your code lints (`pnpm lint:fix`). For new features, please make sure that there is an issue related to it. **Learn more about contributing [here](https://github.com/fkhadra/react-toastify/blob/master/CONTRIBUTING.md)** ================================================ FILE: .github/workflows/build.yaml ================================================ name: React-toastify CI on: [pull_request, push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.2.2 - name: Install node uses: actions/setup-node@v4.1.0 with: node-version: '22.x' - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 9 - name: Install dependencies run: pnpm i # - name: Lint # run: yarn lint - name: Setup run: pnpm run setup - name: Build run: pnpm build - name: Test run: pnpm run test:run - uses: actions/upload-artifact@v3 if: failure() with: name: cypress-screenshots path: cypress/screenshots - uses: actions/upload-artifact@v3 if: always() with: name: cypress-videos path: cypress/videos - name: Coveralls GitHub Action uses: coverallsapp/github-action@v2.3.4 with: github-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .gitignore ================================================ .idea/ node_modules/ lib/ .sass-cache/ npm-debug.log coverage/ yarn-error.log .DS_STORE cjs/ esm/ dist/ .cache /addons .nyc_output cypress/videos/* cypress/screenshots/* .husky ================================================ FILE: .nycrc.json ================================================ { "all": true, "extends": "@istanbuljs/nyc-config-typescript", "check-coverage": true, "include": [ "src/**/*.ts", "src/**/*.tsx" ], "exclude": [ "cypress/**/*.*", "src/types.ts", "**/*.d.ts", "**/*.cy.tsx", "**/*.cy.ts" ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at fdkhadra@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: When contributing to this repository, please first discuss the change you wish to make via issue before making a change. Please note we have a code of conduct, please follow it in all your interactions with the project. ## General Guidelines - Before starting to work on something, please open an issue first - If adding a new feature, write the corresponding test - Ensure that nothing get broke. You can use the playground for that - If applicable, update the [documentation](https://github.com/fkhadra/react-toastify-doc) - Use prettier before committing 😭 - When solving a bug, please provide the steps to reproduce it(codesandbox or stackblitz are our best friends for that) - Tchill 👌 ## Setup ### Pre-requisites - *Node:* `^18.0.0` - *Yarn* ### Install Clone the repository and create a local branch: ```sh git clone https://github.com/fkhadra/react-toastify.git cd react-toastify git checkout -b my-branch ``` Install dependencies: ```sh pnpm install // then pnpm setup ``` ## Developing ```sh # launch the playground pnpm start # Run tests 💩 pnpm test # Prettify all the things pnpm prettier ``` ### Playground dir 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. ### Src - [toast:](https://github.com/fkhadra/react-toastify/blob/main/src/core/toast.ts) Contain the exposed api (`toast.success...`). ## License By contributing, you agree that your contributions will be licensed under its [MIT License](https://github.com/fkhadra/react-toastify/blob/main/LICENSE). ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Fadi Khadra Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # React-Toastify [![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) ![npm](https://img.shields.io/npm/dm/react-toastify.svg?label=%E2%8F%ACdownloads&style=for-the-badge) ![npm](https://img.shields.io/npm/v/react-toastify.svg?style=for-the-badge) ![NPM](https://img.shields.io/npm/l/react-toastify.svg?label=%F0%9F%93%9Clicense&style=for-the-badge) ![Coveralls github](https://img.shields.io/coveralls/github/fkhadra/react-toastify.svg?label=%E2%9B%B1coverage&style=for-the-badge) ![React toastify](https://user-images.githubusercontent.com/5574267/130804494-a9d2d69c-f170-4576-b2e1-0bb7f13dd92d.gif "React toastify") ![stacked](https://github.com/fkhadra/react-toastify/assets/5574267/975c7c01-b95e-43cf-9100-256fa8ef2760) ![custom-style](https://github.com/user-attachments/assets/311672f7-f98a-46f3-a2ab-a9d1a05186a7) 🎉 React-Toastify allows you to add notifications to your app with ease. ## Installation ``` $ npm install --save react-toastify $ yarn add react-toastify ``` ```jsx import React from 'react'; import { ToastContainer, toast } from 'react-toastify'; function App(){ const notify = () => toast("Wow so easy!"); return (
); } ``` ## Documentation Check the [documentation](https://fkhadra.github.io/react-toastify/introduction) to get you started! ## Features - Easy to set up for real, you can make it work in less than 10sec! - Super easy to customize - RTL support - Swipe to close 👌 - Can choose swipe direction - Super easy to use an animation of your choice. Works well with animate.css for example - Can display a react component inside the toast! - Has ```onOpen``` and ```onClose``` hooks. Both can access the props passed to the react component rendered inside the toast - Can remove a toast programmatically - Define behavior per toast - Pause toast when the window loses focus 👁 - Fancy progress bar to display the remaining time - Possibility to update a toast - You can control the progress bar a la `nprogress` 😲 - You can limit the number of toast displayed at the same time - Dark mode 🌒 - Pause timer programmaticaly - Stacked notifications! - And much more ! ## Demo [A demo is worth a thousand words](https://fkhadra.github.io/react-toastify/introduction) ## Contribute Show your ❤️ and support by giving a ⭐. Any suggestions are welcome! Take a look at the contributing guide. You can also find me on [reactiflux](https://www.reactiflux.com/). My pseudo is Fadi. ## Contributors ### Code Contributors This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. ### Financial Contributors Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/react-toastify/contribute)] #### Individuals #### Organizations 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)] ## Release Notes You can find the release note for the latest release [here](https://github.com/fkhadra/react-toastify/releases/latest) You can browse them all [here](https://github.com/fkhadra/react-toastify/releases) ## License Licensed under MIT ================================================ FILE: cypress/support/commands.ts ================================================ /// /// // *********************************************** // This example commands.ts shows you how to // create various custom commands and overwrite // existing commands. // // For more comprehensive examples of custom // commands please read more here: // https://on.cypress.io/custom-commands // *********************************************** // // // -- This is a parent command -- // Cypress.Commands.add('login', (email, password) => { ... }) // // // -- This is a child command -- // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) // // // -- This is a dual command -- // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) // // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) // declare global { namespace Cypress { interface Chainable { resolveEntranceAnimation(): void; } } } import '@4tw/cypress-drag-drop'; import '@testing-library/cypress/add-commands'; Cypress.Commands.add('resolveEntranceAnimation', () => { cy.wait(800); }); ================================================ FILE: cypress/support/component-index.html ================================================ Components App
================================================ FILE: cypress/support/component.ts ================================================ // *********************************************************** // This example support/component.ts is processed and // loaded automatically before your test files. // // This is a great place to put global configuration and // behavior that modifies Cypress. // // You can change the location of this file or turn off // automatically serving support files with the // 'supportFile' configuration option. // // You can read more here: // https://on.cypress.io/configuration // *********************************************************** // Import commands.js using ES2015 syntax: // cypress/support/e2e.js import '@cypress/code-coverage/support'; import './commands'; import './style.css'; import '../../src/style.css'; // Alternatively you can use CommonJS syntax: // require('./commands') import { mount } from 'cypress/react18'; // Augment the Cypress namespace to include type definitions for // your custom command. // Alternatively, can be defined in cypress/support/component.d.ts // with a at the top of your spec. declare global { namespace Cypress { interface Chainable { mount: typeof mount; } } } Cypress.Commands.add('mount', mount); // Example use: // cy.mount() ================================================ FILE: cypress/support/style.css ================================================ [data-cy-root]{ height: 80vh; } ================================================ FILE: cypress.config.ts ================================================ import { defineConfig } from 'cypress'; export default defineConfig({ component: { setupNodeEvents(on, config) { require('@cypress/code-coverage/task')(on, config); return config; }, devServer: { framework: 'react', bundler: 'vite' } } }); ================================================ FILE: lefthook.yml ================================================ pre-commit: parallel: true commands: lint-staged: glob: "*.{js,ts,jsx,tsx,css}" run: pnpm lint-staged ================================================ FILE: package.json ================================================ { "version": "11.0.5", "license": "MIT", "description": "React notification made easy", "keywords": [ "react", "notification", "toast", "react-component", "react-toastify", "push", "alert", "snackbar", "message" ], "files": [ "dist", "addons" ], "scripts": { "prepare": "lefthook install", "setup": "pnpm link .", "start": "cd playground && pnpm dev", "test": "cypress open --component", "test:run": "cypress run --component -b chrome", "prettier": "prettier --write src", "build": "tsup && cp src/style.css dist/ReactToastify.css && rm dist/unstyled.css*" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "prettier": { "printWidth": 120, "semi": true, "singleQuote": true, "trailingComma": "none", "arrowParens": "avoid" }, "name": "react-toastify", "repository": { "type": "git", "url": "git+https://github.com/fkhadra/react-toastify.git" }, "author": "Fadi Khadra (https://fkhadra.github.io)", "bugs": { "url": "https://github.com/fkhadra/react-toastify/issues" }, "homepage": "https://github.com/fkhadra/react-toastify#readme", "devDependencies": { "@4tw/cypress-drag-drop": "^2.2.5", "@cypress/code-coverage": "^3.13.9", "@istanbuljs/nyc-config-typescript": "^1.0.2", "@testing-library/cypress": "^10.0.2", "@types/node": "^22.10.2", "@types/react": "^19.0.1", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", "coveralls": "^3.1.1", "cypress": "^13.16.1", "lefthook": "^1.9.2", "lint-staged": "^15.2.11", "postcss": "^8.4.49", "prettier": "3.4.2", "react": "^19.0.0", "react-dom": "^19.0.0", "tsup": "^8.3.5", "typescript": "^5.7.2", "vite": "^6.0.3", "vite-plugin-istanbul": "^6.0.2" }, "dependencies": { "clsx": "^2.1.1" }, "main": "dist/index.js", "typings": "dist/index.d.ts", "module": "dist/index.mjs", "source": "src/index.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" }, "./unstyled": { "types": "./dist/unstyled.d.ts", "import": "./dist/unstyled.mjs", "require": "./dist/unstyled.js" }, "./dist/ReactToastify.css": "./dist/ReactToastify.css", "./ReactToastify.css": "./dist/ReactToastify.css", "./package.json": "./package.json", "./addons/use-notification-center": { "types": "./addons/use-notification-center/index.d.ts", "import": "./addons/use-notification-center/index.mjs", "require": "./addons/use-notification-center/index.js" }, "./notification-center": { "types": "./addons/use-notification-center/index.d.ts", "import": "./addons/use-notification-center/index.mjs", "require": "./addons/use-notification-center/index.js" } }, "lint-staged": { "*.{js,jsx,ts,tsx,md,html,css}": "prettier --write" } } ================================================ FILE: playground/.eslintrc.cjs ================================================ module.exports = { env: { browser: true, es2020: true }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', ], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, plugins: ['react-refresh'], rules: { 'react-refresh/only-export-components': 'warn', }, } ================================================ FILE: playground/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: playground/index.html ================================================ Vite + React + TS
================================================ FILE: playground/package.json ================================================ { "name": "playground", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, "devDependencies": { "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.2", "vite": "^6.0.1" } } ================================================ FILE: playground/src/components/App.tsx ================================================ /** * The playground could use some love 💖. To the brave soul reading this * message, any help would be appreciated 🙏 * * The code is full of bad assertion 😆 */ import { Checkbox } from './Checkbox'; import { ContainerCode, ContainerCodeProps } from './ContainerCode'; import { Header } from './Header'; import { Radio } from './Radio'; import { ToastCode, ToastCodeProps } from './ToastCode'; import { flags, positions, themes, transitions, typs } from './constants'; import React from 'react'; import { Id, toast, ToastContainer } from '../../../src'; import { defaultProps } from '../../../src/components/ToastContainer'; // Attach to window. Can be useful to debug // @ts-ignore window.toast = toast; class App extends React.Component { state = App.getDefaultState(); toastId: Id; resolvePromise = true; static getDefaultState() { return { ...defaultProps, transition: 'bounce', type: 'default', progress: '', disableAutoClose: false, limit: 0, theme: 'light' }; } handleReset = () => this.setState({ ...App.getDefaultState() }); clearAll = () => toast.dismiss(); showToast = () => { this.toastId = this.state.type === 'default' ? toast('🦄 Wow so easy !', { progress: this.state.progress }) : toast[this.state.type]('🚀 Wow so easy !', { progress: this.state.progress }); }; firePromise = () => { toast.promise( new Promise((resolve, reject) => { setTimeout(() => { this.resolvePromise ? resolve(null) : reject(null); this.resolvePromise = !this.resolvePromise; }, 3000); }), { pending: 'Promise is pending', success: 'Promise resolved 👌', error: 'Promise rejected 🤯' } ); }; updateToast = () => toast.update(this.toastId, { progress: this.state.progress }); handleAutoCloseDelay = e => this.setState({ autoClose: e.target.value > 0 ? parseInt(e.target.value, 10) : 1 }); isDefaultProps() { return ( this.state.position === 'top-right' && this.state.autoClose === 5000 && !this.state.disableAutoClose && !this.state.hideProgressBar && !this.state.newestOnTop && !this.state.rtl && this.state.pauseOnFocusLoss && this.state.pauseOnHover && this.state.closeOnClick && this.state.draggable && this.state.theme === 'light' ); } handleRadioOrSelect = e => this.setState({ [e.target.name]: e.target.name === 'limit' ? parseInt(e.target.value, 10) : e.target.value }); toggleCheckbox = e => this.setState({ [e.target.name]: !this.state[e.target.name] }); renderFlags() { return flags.map(({ id, label }) => (
  • )); } render() { return (

    By default, all toasts will inherit ToastContainer's props. Props defined on toast supersede ToastContainer's props. Props marked with * can only be set on the ToastContainer. The demo is not exhaustive, check the repo for more!

    Position

    Type

    Options

      {this.renderFlags()}
    ); } } export { App }; ================================================ FILE: playground/src/components/Checkbox.tsx ================================================ import * as React from 'react'; interface CheckboxProps { label: string; id: string; checked: boolean; onChange: (e: React.ChangeEvent) => void; } export const Checkbox = ({ label, onChange, id, checked }: CheckboxProps) => ( ); ================================================ FILE: playground/src/components/ContainerCode.tsx ================================================ import * as React from 'react'; import { ToastContainerProps } from '../../../src'; function getProp(prop: L, value: R) { return value ? (
    {prop}
    ) : (
    {prop} {`={false}`}
    ); } export interface ContainerCodeProps extends Partial { isDefaultProps: boolean; disableAutoClose: boolean; } export const ContainerCode: React.FC = ({ position, disableAutoClose, autoClose, hideProgressBar, newestOnTop, closeOnClick, pauseOnHover, rtl, pauseOnFocusLoss, isDefaultProps, draggable, theme }) => (

    Toast Container

    {`<`} ToastContainer
    position {`="${position}"`}
    theme {`="${theme}"`}
    autoClose {`={${disableAutoClose ? false : autoClose}}`}
    {!disableAutoClose ? getProp('hideProgressBar', hideProgressBar) : ''} {getProp('newestOnTop', newestOnTop)} {getProp('closeOnClick', closeOnClick)} {getProp('rtl', rtl)} {getProp('pauseOnFocusLoss', pauseOnFocusLoss)} {getProp('draggable', draggable)} {!disableAutoClose ? getProp('pauseOnHover', pauseOnHover) : ''}
    {`/>`}
    {isDefaultProps && (
    {`{/* Same as */}`}
    {`<`} ToastContainer {'/>'}
    )}
    ); ================================================ FILE: playground/src/components/Header.tsx ================================================ import * as React from 'react'; export const Header = () => (

    Welcome to React-toastify

    React notification made easy !
    ); ================================================ FILE: playground/src/components/Radio.tsx ================================================ import * as React from 'react'; interface RadioProps { options: Record; name: string; onChange: (e: React.ChangeEvent) => void; checked: string | boolean; } export const Radio = ({ options, name, onChange, checked = false }: RadioProps) => ( <> {Object.keys(options).map(k => { const option = options[k]; return (
  • ); })} ); ================================================ FILE: playground/src/components/ToastCode.tsx ================================================ import * as React from 'react'; import { themes } from './constants'; function getType(type: string) { switch (type) { case 'default': default: return 'toast'; case 'success': return 'toast.success'; case 'error': return 'toast.error'; case 'info': return 'toast.info'; case 'warning': return 'toast.warn'; } } export interface ToastCodeProps { position: string; disableAutoClose: boolean; autoClose: boolean | number; hideProgressBar: boolean; closeOnClick: boolean; pauseOnHover: boolean; type: string; draggable: boolean; progress: number; theme: typeof themes[number]; } export const ToastCode: React.FC = ({ position, disableAutoClose, autoClose, hideProgressBar, closeOnClick, pauseOnHover, type, draggable, progress, theme }) => (

    Toast Emitter

    {getType(type)} {`('🦄 Wow so easy!', { `}
    position {`: "${position}"`},
    theme {`: "${theme}"`},
    autoClose {`: ${disableAutoClose ? false : autoClose}`},
    hideProgressBar {`: ${hideProgressBar ? 'true' : 'false'}`},
    closeOnClick {`: ${closeOnClick ? 'true' : 'false'}`},
    pauseOnHover {`: ${pauseOnHover ? 'true' : 'false'}`},
    draggable {`: ${draggable ? 'true' : 'false'}`},
    {!Number.isNaN(progress) && (
    progress {`: ${progress}`},
    )}
    {`});`}
    ); ================================================ FILE: playground/src/components/constants.ts ================================================ import { Bounce, Slide, Flip, Zoom } from '../../../src/index'; export const flags = [ { id: 'disableAutoClose', label: 'Disable auto-close' }, { id: 'hideProgressBar', label: 'Hide progress bar(less fanciness!)' }, { id: 'newestOnTop', label: 'Newest on top*' }, { id: 'closeOnClick', label: 'Close on click' }, { id: 'pauseOnHover', label: 'Pause delay on hover' }, { id: 'pauseOnFocusLoss', label: 'Pause toast when the window loses focus' }, { id: 'rtl', label: 'Right to left layout*' }, { id: 'draggable', label: 'Allow to drag and close the toast' } ]; export const transitions = { bounce: Bounce, slide: Slide, zoom: Zoom, flip: Flip }; export const themes = ['light', 'dark', 'colored']; export const positions = { TOP_LEFT: 'top-left', TOP_RIGHT: 'top-right', TOP_CENTER: 'top-center', BOTTOM_LEFT: 'bottom-left', BOTTOM_RIGHT: 'bottom-right', BOTTOM_CENTER: 'bottom-center' }; export const typs = { INFO: 'info', SUCCESS: 'success', WARNING: 'warning', ERROR: 'error', DEFAULT: 'default' }; ================================================ FILE: playground/src/index.css ================================================ @import url(https://fonts.googleapis.com/css?family=Titillium+Web); body { margin: 0; padding: 0; font-family: 'Titillium Web', sans-serif; min-height: 100vh; background: linear-gradient(110deg, #1d4350, #a43931); color: #fff; } * { box-sizing: border-box; } main { display: grid; grid-template-rows: auto 1fr; grid-gap: 20px; } header { background: #222; text-align: center; padding: 30px 0; } h3 { color: #fff; } header h1 { margin-top: 0; } ul { list-style: none; padding: 0; } input[type='number'] { padding: 8px; background-color: transparent; box-shadow: none; border: 1px solid; margin: 0 5px; border-radius: 5px; border-color: #ac557b; color: #fff; width: 100px; } input[type='radio'] { margin-right: 8px; } select { padding: 8px; padding: 8px; background-color: transparent; box-shadow: none; border: 1px solid; border-top-color: currentcolor; border-right-color: currentcolor; border-bottom-color: currentcolor; border-left-color: currentcolor; border-top-color: currentcolor; border-right-color: currentcolor; border-bottom-color: currentcolor; border-left-color: currentcolor; margin: 0 5px; border-radius: 5px; border-color: #ac557b; color: #fff; -webkit-appearance: none; -moz-appearance: none; } .container { max-width: 1080px; margin: auto; width: 100%; background: rgba(255, 255, 255, 0.1); padding: 20px; border-radius: 10px; display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } .container p { grid-column: 1 / -1; font-size: 13px; font-style: italic; background: #222; padding: 5px; border-left: 3px solid #a9547e; } .container__options { display: grid; grid-template-columns: repeat(2, 1fr); } .container__options div:last-child { grid-column: 1 / -1; } .container__actions { display: flex; } .cta__wrapper { grid-column: span 2; } .btn { color: #fff; text-decoration: none; padding: 8px 16px; margin: 0 15px 0 0; background: linear-gradient(100deg, #e96443, #904e95); box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12), 0 3px 1px -2px rgba(0, 0, 0, 0.2); border: none; text-transform: capitalize; cursor: pointer; transition: transform 0.3s; min-width: 120px; } .btn:hover { transform: scale(1.1); } .bg-red { background: #d13c3c; } .bg-blue { background: #3b4149; } .code { font-family: 'Source Code Pro', Menlo, Monaco, Courier, monospace; font-size: 12px; line-height: 1.4; font-style: normal; border-left: 3px solid #a9547e; padding-left: 20px; background: #222; } .code__component { color: #66d9ef; } .code__props { color: #a6e22e; } .code div { margin-left: 20px; } .code div:first-child, .code div:last-child { margin: 0; } .github-corner:hover .octo-arm { animation: octocat-wave 560ms ease-in-out; } .options_wrapper { display: grid; grid-template-columns: repeat(3, auto); gap: 18px; } @keyframes octocat-wave { 0%, 100% { transform: rotate(0); } 20%, 60% { transform: rotate(-25deg); } 40%, 80% { transform: rotate(10deg); } } @media (max-width: 500px) { .github-corner:hover .octo-arm { animation: none; } .github-corner .octo-arm { animation: octocat-wave 560ms ease-in-out; } } ================================================ FILE: playground/src/main.tsx ================================================ import React from 'react'; import ReactDOM from 'react-dom/client'; import { App } from './components/App'; import './index.css'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ); ================================================ FILE: playground/src/vite-env.d.ts ================================================ /// ================================================ FILE: playground/tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": ["DOM", "DOM.Iterable", "ESNext"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": false, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: playground/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: playground/vite.config.ts ================================================ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], }) ================================================ FILE: src/addons/use-notification-center/NotificationCenter.cy.tsx ================================================ import React from 'react'; import { toast, ToastContainer } from 'react-toastify'; import { NotificationCenterItem, useNotificationCenter, UseNotificationCenterParams } from './useNotificationCenter'; function TestComponent(props: UseNotificationCenterParams) { const [content, setContent] = React.useState(''); const [updateId, setUpdateId] = React.useState(''); const { unreadCount, markAllAsRead, markAsRead, notifications, remove, add, clear, update } = useNotificationCenter( props || {} ); const flex = { display: 'flex', gap: '1rem', alignItems: 'center' }; return (
    • count {notifications.length}
    • unread count {unreadCount}
    setContent(e.target.value)} value={content} /> setUpdateId(e.target.value)} value={updateId} />
      {notifications.map(el => (
    • {/* @ts-ignore */} {el.content} {el.read.toString()}
    • ))}
    ); } describe('NotificationCenter', () => { beforeEach(() => { cy.mount(); }); it('listen for new notifications', () => { cy.findByTestId('count').should('contain.text', 0); cy.findByTestId('unreadCount').should('contain.text', 0); // hacky asf??? cy.wait(1000).then(() => { toast('msg'); cy.findByTestId('count').should('contain.text', 1, { timeout: 10000 }); cy.findByTestId('unreadCount').should('contain.text', 1); }); }); it('add notification', () => { cy.findByTestId('count').should('contain.text', 0); cy.findByTestId('unreadCount').should('contain.text', 0); cy.findByTestId('content').type('something'); cy.findByText('addNotification').click(); cy.findByText('something').should('exist'); cy.findByTestId('count').should('contain.text', 1); cy.findByTestId('unreadCount').should('contain.text', 1); }); it('update', () => { const id = toast('msg'); cy.resolveEntranceAnimation(); cy.findByRole('alert').should('exist'); setTimeout(() => { toast.update(id, { render: 'msg updated' }); }, 0); cy.findAllByText('msg updated').should('exist'); }); describe('with initial state', () => { const initialState: NotificationCenterItem[] = [ { id: 1, createdAt: Date.now(), read: false, content: 'noti1' }, { id: 2, createdAt: Date.now(), read: true, content: 'noti2' } ]; beforeEach(() => { cy.mount(); }); it('handle initial state', () => { cy.findByTestId('count').should('contain.text', initialState.length); cy.findByTestId('unreadCount').should('contain.text', 1); initialState.forEach(v => { cy.findByText(v.content as string).should('exist'); }); }); it('clear all', () => { cy.findByTestId('count').should('contain.text', initialState.length); cy.findByTestId('unreadCount').should('contain.text', 1); cy.findByText('clear').click(); cy.findByTestId('count').should('contain.text', 0); cy.findByTestId('unreadCount').should('contain.text', 0); }); it('mark all as read', () => { cy.findByTestId('unreadCount').should('contain.text', 1); cy.findByText('markAllAsRead').click(); cy.findByTestId('unreadCount').should('contain.text', 0); }); }); }); ================================================ FILE: src/addons/use-notification-center/index.ts ================================================ export * from './useNotificationCenter'; ================================================ FILE: src/addons/use-notification-center/useNotificationCenter.ts ================================================ import { useState, useEffect, useRef } from 'react'; import { toast, ToastItem, Id } from 'react-toastify'; type Optional = Pick, K> & Omit; export interface NotificationCenterItem extends Optional, 'content' | 'data' | 'status'> { read: boolean; createdAt: number; } export type SortFn = (l: NotificationCenterItem, r: NotificationCenterItem) => number; export type FilterFn = (item: NotificationCenterItem) => boolean; export interface UseNotificationCenterParams { /** * initial data to rehydrate the notification center */ data?: NotificationCenterItem[]; /** * By default, the notifications are sorted from the newest to the oldest using * the `createdAt` field. Use this to provide your own sort function * * Usage: * ``` * // old notifications first * useNotificationCenter({ * sort: ((l, r) => l.createdAt - r.createdAt) * }) * ``` */ sort?: SortFn; /** * Keep the toast that meets the condition specified in the callback function. * * Usage: * ``` * // keep only the toasts when hidden is set to false * useNotificationCenter({ * filter: item => item.data.hidden === false * }) * ``` */ filter?: FilterFn; } export interface UseNotificationCenter { /** * Contains all the notifications */ notifications: NotificationCenterItem[]; /** * Clear all notifications */ clear(): void; /** * Mark all notification as read */ markAllAsRead(): void; /** * Mark all notification as read or not. * * Usage: * ``` * markAllAsRead(false) // mark all notification as not read * * markAllAsRead(true) // same as calling markAllAsRead() * ``` */ markAllAsRead(read?: boolean): void; /** * Mark one or more notifications as read. * * Usage: * ``` * markAsRead("anId") * markAsRead(["a","list", "of", "id"]) * ``` */ markAsRead(id: Id | Id[]): void; /** * Mark one or more notifications as read.The second parameter let you mark the notification as read or not. * * Usage: * ``` * markAsRead("anId", false) * markAsRead(["a","list", "of", "id"], false) * * markAsRead("anId", true) // same as markAsRead("anId") * ``` */ markAsRead(id: Id | Id[], read?: boolean): void; /** * Remove one or more notifications * * Usage: * ``` * remove("anId") * remove(["a","list", "of", "id"]) * ``` */ remove(id: Id | Id[]): void; /** * Push a notification to the notification center. * Returns null when an item with the given id already exists * * Usage: * ``` * const id = add({id: "id", content: "test", data: { foo: "hello" } }) * * // Return the id of the notification, generate one if none provided * const id = add({ data: {title: "a title", text: "some text"} }) * ``` */ add(item: Partial>): Id | null; /** * Update the notification that match the id * Returns null when no matching notification found * * Usage: * ``` * const id = update("anId", {content: "test", data: { foo: "hello" } }) * * // It's also possible to update the id * const id = update("anId"m { id:"anotherOne", data: {title: "a title", text: "some text"} }) * ``` */ update(id: Id, item: Partial>): Id | null; /** * Retrieve one or more notifications * * Usage: * ``` * find("anId") * find(["a","list", "of", "id"]) * ``` */ find(id: Id): NotificationCenterItem | undefined; /** * Retrieve one or more notifications * * Usage: * ``` * find("anId") * find(["a","list", "of", "id"]) * ``` */ find(id: Id[]): NotificationCenterItem[] | undefined; /** * Retrieve the count for unread notifications */ unreadCount: number; /** * Sort notifications using the newly provided function * * Usage: * ``` * // old notifications first * sort((l, r) => l.createdAt - r.createdAt) * ``` */ sort(sort: SortFn): void; } export function useNotificationCenter( params: UseNotificationCenterParams = {} ): UseNotificationCenter { const sortFn = useRef(params.sort || defaultSort); const filterFn = useRef(params.filter || null); const [notifications, setNotifications] = useState[]>(() => { if (params.data) { return filterFn.current ? params.data.filter(filterFn.current).sort(sortFn.current) : [...params.data].sort(sortFn.current); } return []; }); useEffect(() => { return toast.onChange(item => { if (item.status === 'added' || item.status === 'updated') { const newItem = decorate(item as NotificationCenterItem); if (filterFn.current && !filterFn.current(newItem)) return; setNotifications(prev => { let nextState: NotificationCenterItem[] = []; const updateIdx = prev.findIndex(v => v.id === newItem.id); if (updateIdx !== -1) { nextState = prev.slice(); Object.assign(nextState[updateIdx], newItem, { createdAt: Date.now() }); } else if (prev.length === 0) { nextState = [newItem]; } else { nextState = [newItem, ...prev]; } return nextState.sort(sortFn.current); }); } }); }, []); const remove = (id: Id | Id[]) => { setNotifications(prev => prev.filter(Array.isArray(id) ? v => !id.includes(v.id) : v => v.id !== id)); }; const clear = () => { setNotifications([]); }; const markAllAsRead = (read = true) => { setNotifications(prev => prev.map(v => { v.read = read; return v; }) ); }; const markAsRead = (id: Id | Id[], read = true) => { let map = (v: NotificationCenterItem) => { if (v.id === id) v.read = read; return v; }; if (Array.isArray(id)) { map = v => { if (id.includes(v.id)) v.read = read; return v; }; } setNotifications(prev => prev.map(map)); }; const find = (id: Id | Id[]) => { return Array.isArray(id) ? notifications.filter(v => id.includes(v.id)) : notifications.find(v => v.id === id); }; const add = (item: Partial>) => { if (notifications.find(v => v.id === item.id)) return null; const newItem = decorate(item); setNotifications(prev => [...prev, newItem].sort(sortFn.current)); return newItem.id; }; const update = (id: Id, item: Partial>) => { const index = notifications.findIndex(v => v.id === id); if (index !== -1) { setNotifications(prev => { const nextState = [...prev]; Object.assign(nextState[index], item, { createdAt: item.createdAt || Date.now() }); return nextState.sort(sortFn.current); }); return item.id as Id; } return null; }; const sort = (compareFn: SortFn) => { sortFn.current = compareFn; setNotifications(prev => prev.slice().sort(compareFn)); }; return { notifications, clear, markAllAsRead, markAsRead, add, update, remove, // @ts-ignore fixme: overloading issue find, sort, get unreadCount() { return notifications.reduce((prev, cur) => (!cur.read ? prev + 1 : prev), 0); } }; } export function decorate(item: NotificationCenterItem | Partial>) { if (item.id == null) item.id = Date.now().toString(36).substring(2, 9); if (!item.createdAt) item.createdAt = Date.now(); if (item.read == null) item.read = false; return item as NotificationCenterItem; } // newest to oldest function defaultSort(l: NotificationCenterItem, r: NotificationCenterItem) { return r.createdAt - l.createdAt; } ================================================ FILE: src/components/CloseButton.cy.tsx ================================================ import React from 'react'; import { CloseButton } from './CloseButton'; describe('CloseButton', () => { it('call close toast when clicking', () => { const closeToast = cy.stub().as('closeToast'); cy.mount(); cy.get('@closeToast').should('not.have.been.called'); cy.findByRole('button').click(); cy.get('@closeToast').should('have.been.called'); }); it('have a default aria-label', () => { cy.mount(); cy.findByLabelText('close').should('exist'); }); it('set aria-label', () => { cy.mount(); cy.findByLabelText('foobar').should('exist'); }); }); ================================================ FILE: src/components/CloseButton.tsx ================================================ import React from 'react'; import { Default } from '../utils'; import { CloseToastFunc, Theme, TypeOptions } from '../types'; export interface CloseButtonProps { closeToast: CloseToastFunc; type: TypeOptions; ariaLabel?: string; theme: Theme; } export function CloseButton({ closeToast, theme, ariaLabel = 'close' }: CloseButtonProps) { return ( ); } ================================================ FILE: src/components/Icons.cy.tsx ================================================ import React from 'react'; import { TypeOptions } from '../types'; import { IconParams, getIcon } from './Icons'; const props: IconParams = { theme: 'light', type: 'default', isLoading: false }; describe('Icons', () => { it('handle function', () => { const C = getIcon({ ...props, icon: () =>
    icon
    }); cy.mount(C); cy.findByText('icon').should('exist'); }); it('handle react element', () => { const C = getIcon({ ...props, icon:
    icon
    }); cy.mount(C); cy.findByText('icon').should('exist'); }); it('handle loader', () => { const C = getIcon({ ...props, isLoading: true }); cy.mount(C); cy.get('[data-cy-root]').should('have.length', 1); }); it('handle built-in icons', () => { for (const t of ['info', 'warning', 'success', 'error', 'spinner']) { const C = getIcon({ ...props, type: t as TypeOptions }); cy.mount(C); cy.get('[data-cy-root]').should('have.length', 1); } }); }); ================================================ FILE: src/components/Icons.tsx ================================================ import React, { cloneElement, isValidElement } from 'react'; import { Theme, ToastProps, TypeOptions } from '../types'; import { Default, isFn } from '../utils'; /** * Used when providing custom icon */ export interface IconProps { theme: Theme; type: TypeOptions; isLoading?: boolean; } export type BuiltInIconProps = React.SVGProps & IconProps; const Svg: React.FC = ({ theme, type, isLoading, ...rest }) => ( ); function Warning(props: BuiltInIconProps) { return ( ); } function Info(props: BuiltInIconProps) { return ( ); } function Success(props: BuiltInIconProps) { return ( ); } function Error(props: BuiltInIconProps) { return ( ); } function Spinner() { return
    ; } export const Icons = { info: Info, warning: Warning, success: Success, error: Error, spinner: Spinner }; const maybeIcon = (type: string): type is keyof typeof Icons => type in Icons; export type IconParams = Pick; export function getIcon({ theme, type, isLoading, icon }: IconParams) { let Icon: React.ReactNode = null; const iconProps = { theme, type }; if (icon === false) { // hide } else if (isFn(icon)) { Icon = icon({ ...iconProps, isLoading }); } else if (isValidElement(icon)) { Icon = cloneElement(icon, iconProps); } else if (isLoading) { Icon = Icons.spinner(); } else if (maybeIcon(type)) { Icon = Icons[type](iconProps); } return Icon; } ================================================ FILE: src/components/ProgressBar.cy.tsx ================================================ import React from 'react'; import { Theme } from '../types'; import { ProgressBar } from './ProgressBar'; const getProps = () => ({ delay: 5000, isRunning: true, rtl: false, closeToast: cy.stub, isIn: true, theme: ['colored', 'light', 'dark'][Math.floor(Math.random() * 3)] as Theme }); const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
    {children}
    ); describe('ProgressBar', () => { it('merge className', () => { cy.mount( ); cy.get('.test').should('exist'); }); it('merge className in function form', () => { cy.mount( 'test'} /> ); cy.get('.test').should('exist'); }); it('trigger closeToast when animation end', () => { const closeToast = cy.stub().as('closeToast'); const delay = 1000; cy.mount( ); cy.get('@closeToast').should('not.have.been.called'); cy.wait(delay); cy.get('@closeToast').should('have.been.called'); }); it('hide the progress bar', () => { cy.mount( ); cy.get('[role=progressbar]').should('exist').should('not.be.visible'); }); it('pause the progress bar', () => { cy.mount( ); cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'animation-play-state: paused'); }); it('control progress bar', () => { cy.mount( ); cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'scaleX(0.7)'); }); }); ================================================ FILE: src/components/ProgressBar.tsx ================================================ import React from 'react'; import cx from 'clsx'; import { Default, isFn, Type } from '../utils'; import { Theme, ToastClassName, TypeOptions } from '../types'; export interface ProgressBarProps { /** * The animation delay which determine when to close the toast */ delay: number; /** * The animation is running or paused */ isRunning: boolean; /** * Func to close the current toast */ closeToast: () => void; /** * Optional type : info, success ... */ type?: TypeOptions; /** * The theme that is currently used */ theme: Theme; /** * Hide or not the progress bar */ hide?: boolean; /** * Optional className */ className?: ToastClassName; /** * Tell whether a controlled progress bar is used */ controlledProgress?: boolean; /** * Controlled progress value */ progress?: number | string; /** * Support rtl content */ rtl?: boolean; /** * Tell if the component is visible on screen or not */ isIn?: boolean; } export function ProgressBar({ delay, isRunning, closeToast, type = Type.DEFAULT, hide, className, controlledProgress, progress, rtl, isIn, theme }: ProgressBarProps) { const isHidden = hide || (controlledProgress && progress === 0); const style: React.CSSProperties = { animationDuration: `${delay}ms`, animationPlayState: isRunning ? 'running' : 'paused' }; if (controlledProgress) style.transform = `scaleX(${progress})`; const defaultClassName = cx( `${Default.CSS_NAMESPACE}__progress-bar`, controlledProgress ? `${Default.CSS_NAMESPACE}__progress-bar--controlled` : `${Default.CSS_NAMESPACE}__progress-bar--animated`, `${Default.CSS_NAMESPACE}__progress-bar-theme--${theme}`, `${Default.CSS_NAMESPACE}__progress-bar--${type}`, { [`${Default.CSS_NAMESPACE}__progress-bar--rtl`]: rtl } ); const classNames = isFn(className) ? className({ rtl, type, defaultClassName }) : cx(defaultClassName, className); // 🧐 controlledProgress is derived from progress // so if controlledProgress is set // it means that this is also the case for progress const animationEvent = { [controlledProgress && (progress as number)! >= 1 ? 'onTransitionEnd' : 'onAnimationEnd']: controlledProgress && (progress as number)! < 1 ? null : () => { isIn && closeToast(); } }; // TODO: add aria-valuenow, aria-valuemax, aria-valuemin return (
    ); } ================================================ FILE: src/components/Toast.cy.tsx ================================================ import React from 'react'; import { DraggableDirection, ToastProps } from '../types'; import { Default } from '../utils'; import { Toast } from './Toast'; import { defaultProps } from './ToastContainer'; const REQUIRED_PROPS = { ...defaultProps, isIn: true, autoClose: false, closeToast: () => {}, type: 'default', toastId: 'id', key: 'key', collapseAll: () => {} } as ToastProps; const cssClasses = { rtl: `.${Default.CSS_NAMESPACE}__toast--rtl`, closeOnClick: `.${Default.CSS_NAMESPACE}__toast--close-on-click`, progressBar: `.${Default.CSS_NAMESPACE}__progress-bar`, progressBarController: `.${Default.CSS_NAMESPACE}__progress-bar--controlled`, closeButton: `.${Default.CSS_NAMESPACE}__close-button`, container: `.${Default.CSS_NAMESPACE}__toast-container` }; const progressBar = { isRunning: () => { cy.wait(100); cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'animation-play-state: running'); }, isPaused: () => { cy.wait(100); cy.findByRole('progressbar') .should('have.attr', 'style') .and('include', 'animation-play-state: paused') .as('pause progress bar'); }, isControlled: (progress: number) => { cy.wait(100); cy.get(cssClasses.progressBarController).should('exist'); cy.findByRole('progressbar').should('have.attr', 'style').and('include', `scaleX(${progress})`); } }; describe('Toast', () => { for (const { name, className } of [ { name: 'string', className: 'container-class' }, { name: 'function', className: () => 'container-class' } ]) { it(`merge container when using ${name}`, () => { cy.mount( FooBar ); cy.get('.container-class').should('exist'); }); } it('support rtl', () => { cy.mount( FooBar ); cy.get(cssClasses.rtl).should('have.css', 'direction', 'rtl'); }); describe('closeOnClick', () => { it('call closeToast when enabled', () => { const closeToast = cy.stub().as('closeToast'); cy.mount( FooBar ); cy.findByRole('alert').click(); cy.get('@closeToast').should('have.been.called'); }); it('does not call closeToast when disabled', () => { const closeToast = cy.stub().as('closeToast'); cy.mount( FooBar ); cy.findByRole('alert').click(); cy.get('@closeToast').should('not.have.been.called'); }); }); describe('autoClose', () => { it('does not render progress bar when false', () => { cy.mount( FooBar ); cy.findByRole('progressbar').should('not.exist'); }); it('resume and pause progress bar', () => { cy.mount( hello ); cy.resolveEntranceAnimation(); cy.findByRole('alert').should('be.visible').trigger('mouseover'); progressBar.isPaused(); cy.findByRole('alert').trigger('mouseout'); progressBar.isRunning(); cy.findByRole('alert').trigger('mouseover'); progressBar.isPaused(); }); }); it('does not render close button when closeButton is false', () => { cy.mount( FooBar ); cy.findByLabelText('close').should('not.exist'); }); it('resume and pause progress bar when pauseOnFocusLoss is enabled', () => { cy.mount( hello ); cy.resolveEntranceAnimation(); progressBar.isRunning(); cy.window().blur(); progressBar.isPaused(); cy.window().focus(); progressBar.isRunning(); }); it('does not pause progress bar when pauseOnHover is disabled', () => { cy.mount( hello ); cy.resolveEntranceAnimation(); cy.findByRole('alert').trigger('mouseover'); progressBar.isRunning(); }); describe('controller progress bar', () => { it('set the correct progress value bar disregarding autoClose value', () => { cy.mount( hello ); cy.resolveEntranceAnimation(); progressBar.isControlled(0.3); cy.mount( hello ); cy.resolveEntranceAnimation(); progressBar.isControlled(0.3); }); it('call closeToast when progress value is >= 1', () => { const closeToast = cy.stub().as('closeToast'); cy.mount( hello ); cy.findByRole('progressbar').trigger('transitionend'); cy.get('@closeToast').should('have.been.called'); }); }); it('call closeToast when autoClose duration exceeded', () => { const closeToast = cy.stub().as('closeToast'); cy.mount( hello ); cy.get('@closeToast').should('have.been.called'); }); it('attach specified attributes: role, id, etc...', () => { const style: React.CSSProperties = { background: 'purple' }; cy.mount( hello ); cy.resolveEntranceAnimation(); cy.findByRole('status').should('exist'); cy.get('#foo').should('exist'); cy.findByRole('status').should('have.attr', 'style').and('include', 'background: purple'); }); for (const { type, value } of [ { type: 'string', value: 'hello' }, { type: 'react element', value:
    hello
    }, { type: 'function', value: () =>
    hello
    } ]) { it(`render ${type}`, () => { cy.mount({value}); cy.findByText('hello').should('exist'); }); } it('override default closeButton', () => { cy.mount( 💩}> hello ); cy.resolveEntranceAnimation(); cy.findByText('💩').should('exist'); }); it('fallback to default closeButton', () => { cy.mount( hello ); cy.resolveEntranceAnimation(); cy.findByLabelText('close').should('exist'); }); describe('Drag event', () => { beforeEach(() => { cy.viewport('macbook-16'); }); for (const { axis, delta } of [ { axis: 'x', delta: { deltaX: -300 } }, { axis: 'y', delta: { deltaY: 300 } } ]) { it(`close toast when dragging on ${axis}-axis`, () => { cy.mount(
    hello
    ); cy.resolveEntranceAnimation(); cy.findByRole('alert').move(delta); cy.get('@closeToast').should('have.been.called'); }); } for (const { axis, delta } of [ { axis: 'x', delta: { deltaX: -100 } }, { axis: 'y', delta: { deltaY: 40 } } ]) { it(`does not close toast when dragging on ${axis}-axis`, () => { cy.mount(
    hello
    ); cy.resolveEntranceAnimation(); cy.findByRole('alert').move(delta); cy.get('@closeToast').should('not.have.been.called'); }); } }); }); ================================================ FILE: src/components/Toast.tsx ================================================ import cx from 'clsx'; import React, { cloneElement, isValidElement } from 'react'; import { useToast } from '../hooks/useToast'; import { ToastProps } from '../types'; import { Default, isFn, renderContent } from '../utils'; import { CloseButton } from './CloseButton'; import { ProgressBar } from './ProgressBar'; import { getIcon } from './Icons'; export const Toast: React.FC = props => { const { isRunning, preventExitTransition, toastRef, eventHandlers, playToast } = useToast(props); const { closeButton, children, autoClose, onClick, type, hideProgressBar, closeToast, transition: Transition, position, className, style, progressClassName, updateId, role, progress, rtl, toastId, deleteToast, isIn, isLoading, closeOnClick, theme, ariaLabel } = props; const defaultClassName = cx( `${Default.CSS_NAMESPACE}__toast`, `${Default.CSS_NAMESPACE}__toast-theme--${theme}`, `${Default.CSS_NAMESPACE}__toast--${type}`, { [`${Default.CSS_NAMESPACE}__toast--rtl`]: rtl }, { [`${Default.CSS_NAMESPACE}__toast--close-on-click`]: closeOnClick } ); const cssClasses = isFn(className) ? className({ rtl, position, type, defaultClassName }) : cx(defaultClassName, className); const icon = getIcon(props); const isProgressControlled = !!progress || !autoClose; const closeButtonProps = { closeToast, type, theme }; let Close: React.ReactNode = null; if (closeButton === false) { // hide } else if (isFn(closeButton)) { Close = closeButton(closeButtonProps); } else if (isValidElement(closeButton)) { Close = cloneElement(closeButton, closeButtonProps); } else { Close = CloseButton(closeButtonProps); } return (
    {icon != null && (
    {icon}
    )} {renderContent(children, props, !isRunning)} {Close} {!props.customProgressBar && ( )}
    ); }; ================================================ FILE: src/components/ToastContainer.tsx ================================================ import cx from 'clsx'; import React, { useEffect, useRef, useState } from 'react'; import { toast } from '../core'; import { useToastContainer } from '../hooks'; import { useIsomorphicLayoutEffect } from '../hooks/useIsomorphicLayoutEffect'; import { ToastContainerProps, ToastPosition } from '../types'; import { Default, Direction, isFn, parseClassName } from '../utils'; import { Toast } from './Toast'; import { Bounce } from './Transitions'; export const defaultProps: ToastContainerProps = { position: 'top-right', transition: Bounce, autoClose: 5000, closeButton: true, pauseOnHover: true, pauseOnFocusLoss: true, draggable: 'touch', draggablePercent: Default.DRAGGABLE_PERCENT as number, draggableDirection: Direction.X, role: 'alert', theme: 'light', 'aria-label': 'Notifications Alt+T', hotKeys: e => e.altKey && e.code === 'KeyT' }; export function ToastContainer(props: ToastContainerProps) { let containerProps: ToastContainerProps = { ...defaultProps, ...props }; const stacked = props.stacked; const [collapsed, setIsCollapsed] = useState(true); const containerRef = useRef(null); const { getToastToRender, isToastActive, count } = useToastContainer(containerProps); const { className, style, rtl, containerId, hotKeys } = containerProps; function getClassName(position: ToastPosition) { const defaultClassName = cx( `${Default.CSS_NAMESPACE}__toast-container`, `${Default.CSS_NAMESPACE}__toast-container--${position}`, { [`${Default.CSS_NAMESPACE}__toast-container--rtl`]: rtl } ); return isFn(className) ? className({ position, rtl, defaultClassName }) : cx(defaultClassName, parseClassName(className)); } function collapseAll() { if (stacked) { setIsCollapsed(true); toast.play(); } } useIsomorphicLayoutEffect(() => { if (stacked) { const nodes = containerRef.current!.querySelectorAll('[data-in="true"]'); const gap = 12; const isTop = containerProps.position?.includes('top'); let usedHeight = 0; let prevS = 0; Array.from(nodes) .reverse() .forEach((n, i) => { const node = n as HTMLElement; node.classList.add(`${Default.CSS_NAMESPACE}__toast--stacked`); if (i > 0) node.dataset.collapsed = `${collapsed}`; if (!node.dataset.pos) node.dataset.pos = isTop ? 'top' : 'bot'; const y = usedHeight * (collapsed ? 0.2 : 1) + (collapsed ? 0 : gap * i); node.style.setProperty('--y', `${isTop ? y : y * -1}px`); node.style.setProperty('--g', `${gap}`); node.style.setProperty('--s', `${1 - (collapsed ? prevS : 0)}`); usedHeight += node.offsetHeight; prevS += 0.025; }); } }, [collapsed, count, stacked]); useEffect(() => { function focusFirst(e: KeyboardEvent) { const node = containerRef.current; if (hotKeys(e)) { (node.querySelector('[tabIndex="0"]') as HTMLElement)?.focus(); setIsCollapsed(false); toast.pause(); } if (e.key === 'Escape' && (document.activeElement === node || node?.contains(document.activeElement))) { setIsCollapsed(true); toast.play(); } } document.addEventListener('keydown', focusFirst); return () => { document.removeEventListener('keydown', focusFirst); }; }, [hotKeys]); return (
    { if (stacked) { setIsCollapsed(false); toast.pause(); } }} onMouseLeave={collapseAll} aria-live="polite" aria-atomic="false" aria-relevant="additions text" aria-label={containerProps['aria-label']} > {getToastToRender((position, toastList) => { const containerStyle: React.CSSProperties = !toastList.length ? { ...style, pointerEvents: 'none' } : { ...style }; return (
    {toastList.map(({ content, props: toastProps }) => { return ( {content} ); })}
    ); })}
    ); } ================================================ FILE: src/components/Transitions.tsx ================================================ import { cssTransition, Default } from '../utils'; const getConfig = (animationName: string, appendPosition = false) => ({ enter: `${Default.CSS_NAMESPACE}--animate ${Default.CSS_NAMESPACE}__${animationName}-enter`, exit: `${Default.CSS_NAMESPACE}--animate ${Default.CSS_NAMESPACE}__${animationName}-exit`, appendPosition }); const Bounce = cssTransition(getConfig('bounce', true)); const Slide = cssTransition(getConfig('slide', true)); const Zoom = cssTransition(getConfig('zoom')); const Flip = cssTransition(getConfig('flip')); export { Bounce, Slide, Zoom, Flip }; ================================================ FILE: src/components/index.tsx ================================================ export * from './CloseButton'; export * from './ProgressBar'; export { ToastContainer } from './ToastContainer'; export * from './Transitions'; export * from './Toast'; export * from './Icons'; ================================================ FILE: src/core/containerObserver.ts ================================================ import { Id, NotValidatedToastProps, OnChangeCallback, Toast, ToastContainerProps, ToastContent, ToastProps } from '../types'; import { canBeRendered, getAutoCloseDelay, isNum, parseClassName, toToastItem } from '../utils'; type Notify = () => void; export type ContainerObserver = ReturnType; export function createContainerObserver( id: Id, containerProps: ToastContainerProps, dispatchChanges: OnChangeCallback ) { let toastKey = 1; let toastCount = 0; let queue: Toast[] = []; let snapshot: Toast[] = []; let props = containerProps; const toasts = new Map(); const listeners = new Set(); const observe = (notify: Notify) => { listeners.add(notify); return () => listeners.delete(notify); }; const notify = () => { snapshot = Array.from(toasts.values()); listeners.forEach(cb => cb()); }; const shouldIgnoreToast = ({ containerId, toastId, updateId }: NotValidatedToastProps) => { const containerMismatch = containerId ? containerId !== id : id !== 1; const isDuplicate = toasts.has(toastId) && updateId == null; return containerMismatch || isDuplicate; }; const toggle = (v: boolean, id?: Id) => { toasts.forEach(t => { if (id == null || id === t.props.toastId) t.toggle?.(v); }); }; const markAsRemoved = (v: Toast) => { v.props?.onClose?.(v.removalReason); v.isActive = false; }; const removeToast = (id?: Id) => { if (id == null) { toasts.forEach(markAsRemoved); } else { const t = toasts.get(id); if (t) markAsRemoved(t); } notify(); }; const clearQueue = () => { toastCount -= queue.length; queue = []; }; const addActiveToast = (toast: Toast) => { const { toastId, updateId } = toast.props; const isNew = updateId == null; if (toast.staleId) toasts.delete(toast.staleId); toast.isActive = true; toasts.set(toastId, toast); notify(); dispatchChanges(toToastItem(toast, isNew ? 'added' : 'updated')); if (isNew) toast.props.onOpen?.(); }; const buildToast = (content: ToastContent, options: NotValidatedToastProps) => { if (shouldIgnoreToast(options)) return; const { toastId, updateId, data, staleId, delay } = options; const isNotAnUpdate = updateId == null; if (isNotAnUpdate) toastCount++; const toastProps = { ...props, style: props.toastStyle, key: toastKey++, ...Object.fromEntries(Object.entries(options).filter(([_, v]) => v != null)), toastId, updateId, data, isIn: false, className: parseClassName(options.className || props.toastClassName), progressClassName: parseClassName(options.progressClassName || props.progressClassName), autoClose: options.isLoading ? false : getAutoCloseDelay(options.autoClose, props.autoClose), closeToast(reason?: true) { toasts.get(toastId)!.removalReason = reason; removeToast(toastId); }, deleteToast() { const toastToRemove = toasts.get(toastId); if (toastToRemove == null) return; dispatchChanges(toToastItem(toastToRemove, 'removed')); toasts.delete(toastId); toastCount--; if (toastCount < 0) toastCount = 0; if (queue.length > 0) { addActiveToast(queue.shift()); return; } notify(); } } as ToastProps; toastProps.closeButton = props.closeButton; if (options.closeButton === false || canBeRendered(options.closeButton)) { toastProps.closeButton = options.closeButton; } else if (options.closeButton === true) { toastProps.closeButton = canBeRendered(props.closeButton) ? props.closeButton : true; } const activeToast = { content, props: toastProps, staleId } as Toast; // not handling limit + delay by design. Waiting for user feedback first if (props.limit && props.limit > 0 && toastCount > props.limit && isNotAnUpdate) { queue.push(activeToast); } else if (isNum(delay)) { setTimeout(() => { addActiveToast(activeToast); }, delay); } else { addActiveToast(activeToast); } }; return { id, props, observe, toggle, removeToast, toasts, clearQueue, buildToast, setProps(p: ToastContainerProps) { props = p; }, setToggle: (id: Id, fn: (v: boolean) => void) => { const t = toasts.get(id); if (t) t.toggle = fn; }, isToastActive: (id: Id) => toasts.get(id)?.isActive, getSnapshot: () => snapshot }; } ================================================ FILE: src/core/genToastId.ts ================================================ let TOAST_ID = 1; export const genToastId = () => `${TOAST_ID++}`; ================================================ FILE: src/core/index.ts ================================================ export * from './toast'; ================================================ FILE: src/core/store.ts ================================================ import { ClearWaitingQueueParams, Id, NotValidatedToastProps, OnChangeCallback, ToastContainerProps, ToastContent, ToastItem, ToastOptions } from '../types'; import { Default, canBeRendered, isId } from '../utils'; import { ContainerObserver, createContainerObserver } from './containerObserver'; interface EnqueuedToast { content: ToastContent; options: NotValidatedToastProps; } interface RemoveParams { id?: Id; containerId: Id; } const containers = new Map(); let renderQueue: EnqueuedToast[] = []; const listeners = new Set(); const dispatchChanges = (data: ToastItem) => listeners.forEach(cb => cb(data)); const hasContainers = () => containers.size > 0; function flushRenderQueue() { renderQueue.forEach(v => pushToast(v.content, v.options)); renderQueue = []; } export const getToast = (id: Id, { containerId }: ToastOptions) => containers.get(containerId || Default.CONTAINER_ID)?.toasts.get(id); export function isToastActive(id: Id, containerId?: Id) { if (containerId) return !!containers.get(containerId)?.isToastActive(id); let isActive = false; containers.forEach(c => { if (c.isToastActive(id)) isActive = true; }); return isActive; } export function removeToast(params?: Id | RemoveParams) { if (!hasContainers()) { renderQueue = renderQueue.filter(v => params != null && v.options.toastId !== params); return; } if (params == null || isId(params)) { containers.forEach(c => { c.removeToast(params as Id); }); } else if (params && ('containerId' in params || 'id' in params)) { const container = containers.get(params.containerId); container ? container.removeToast(params.id) : containers.forEach(c => { c.removeToast(params.id); }); } } export const clearWaitingQueue = (p: ClearWaitingQueueParams = {}) => { containers.forEach(c => { if (c.props.limit && (!p.containerId || c.id === p.containerId)) { c.clearQueue(); } }); }; export function pushToast(content: ToastContent, options: NotValidatedToastProps) { if (!canBeRendered(content)) return; if (!hasContainers()) renderQueue.push({ content, options }); containers.forEach(c => { c.buildToast(content, options); }); } interface ToggleToastParams { id?: Id; containerId?: Id; } type RegisterToggleOpts = { id: Id; containerId?: Id; fn: (v: boolean) => void; }; export function registerToggle(opts: RegisterToggleOpts) { containers.get(opts.containerId || Default.CONTAINER_ID)?.setToggle(opts.id, opts.fn); } export function toggleToast(v: boolean, opt?: ToggleToastParams) { containers.forEach(c => { if (opt == null || !opt?.containerId) { c.toggle(v, opt?.id); } else if (opt?.containerId === c.id) { c.toggle(v, opt?.id); } }); } export function registerContainer(props: ToastContainerProps) { const id = props.containerId || Default.CONTAINER_ID; return { subscribe(notify: () => void) { const container = createContainerObserver(id, props, dispatchChanges); containers.set(id, container); const unobserve = container.observe(notify); flushRenderQueue(); return () => { unobserve(); containers.delete(id); }; }, setProps(p: ToastContainerProps) { containers.get(id)?.setProps(p); }, getSnapshot() { return containers.get(id)?.getSnapshot(); } }; } export function onChange(cb: OnChangeCallback) { listeners.add(cb); return () => { listeners.delete(cb); }; } ================================================ FILE: src/core/toast.cy.tsx ================================================ import React from 'react'; import { ToastContainer } from '../components'; import { toast } from './toast'; beforeEach(() => { cy.viewport('macbook-15'); }); describe('without container', () => { it('enqueue toasts till container is mounted', () => { toast('msg1'); toast('msg2'); cy.findByText('msg1').should('not.exist'); cy.findByText('msg2').should('not.exist'); cy.mount(); cy.resolveEntranceAnimation(); cy.findByText('msg1').should('exist'); cy.findByText('msg2').should('exist'); }); it('remove toast from render queue', () => { toast('msg1'); const id = toast('msg2'); toast.dismiss(id); cy.mount(); cy.resolveEntranceAnimation(); cy.findByText('msg1').should('exist'); cy.findByText('msg2').should('not.exist'); }); }); describe('with container', () => { beforeEach(() => { cy.mount( <> ); }); it('render toast', () => { cy.mount( <> ); cy.findByRole('button').click(); cy.findByText('msg').should('exist'); }); it('return a new id each time a notification is pushed', () => { const firstId = toast('Hello'); const secondId = toast('Hello'); expect(firstId).not.to.be.eq(secondId); }); it('use the provided toastId from options', () => { const toastId = 11; const id = toast('Hello', { toastId }); expect(id).to.be.eq(toastId); }); it('handle change event', () => { toast.onChange(cy.stub().as('onChange')); const id = 'qq'; cy.mount( <> ); cy.findByRole('button', { name: 'display msg' }).click(); cy.get('@onChange').should('have.been.calledWithMatch', { status: 'added', content: 'msg', data: 'xxxx' }); cy.findByRole('button', { name: 'update' }).click(); cy.get('@onChange').should('have.been.calledWithMatch', { status: 'updated', content: 'world' }); // cy.wait(1000); // cy.findByRole('button', { name: 'remove' }).click(); // // cy.get('@onChange').should('have.been.calledWithMatch', { // status: 'removed' // }); }); it('unsubscribe from change event', () => { const unsub = toast.onChange(cy.stub().as('onChange')); unsub(); cy.findByRole('button').click(); cy.get('@onChange').should('not.have.been.called'); }); describe('sa', () => { // it('be able remove toast programmatically', () => { // const id = 'test'; // // cy.mount( // <> // // // // // ); // // cy.findByRole('button', { name: 'display msg' }).click(); // cy.findByText('msg').should('exist'); // // cy.findByRole('button', { name: 'remove' }).click(); // cy.resolveEntranceAnimation(); // cy.findByText('msg').should('not.exist'); // }); it('pause and resume notification', () => { const id = toast('msg', { autoClose: 10000 }); cy.findByRole('progressbar').as('progressBar'); cy.get('@progressBar') .should('have.attr', 'style') .and('include', 'animation-play-state: running') .then(() => { toast.pause({ id }); cy.get('@progressBar') .should('have.attr', 'style') .and('include', 'animation-play-state: paused') .then(() => { toast.play({ id }); cy.get('@progressBar').should('have.attr', 'style').and('include', 'animation-play-state: running'); }); }); }); }); describe('update function', () => { it('update an existing toast', () => { const id = toast('msg'); cy.resolveEntranceAnimation(); cy.findByText('msg') .should('exist') .then(() => { toast.update(id, { render: 'foobar' }); cy.findByText('msg').should('not.exist'); cy.findByText('foobar').should('exist'); }) .then(() => { toast.update(id, { render: 'bazbar' }); cy.findByText('foobar').should('not.exist'); cy.findByText('bazbar').should('exist'); }); }); it('keep the same content', () => { const id = toast('msg'); cy.resolveEntranceAnimation(); cy.findByText('msg').should('exist'); cy.get('.myClass') .should('not.exist') .then(() => { toast.update(id, { className: 'myClass' }); cy.get('.myClass').should('exist'); cy.findByText('msg').should('exist'); }); }); it('update a toast only when it exists', () => { toast.update(0, { render: 'msg' }); cy.resolveEntranceAnimation(); cy.findByText('msg').should('not.exist'); }); it('update the toastId', () => { const id = toast('msg'); const nextId = 123; cy.resolveEntranceAnimation(); cy.findByText('msg') .should('exist') .then(() => { expect(toast.isActive(id)).to.be.true; toast.update(id, { render: 'foobar', toastId: nextId }); }); cy.findByText('foobar') .should('exist') .then(() => { expect(toast.isActive(id)).to.be.false; expect(toast.isActive(nextId)).to.be.true; }); }); }); it('can append classNames', () => { toast('msg', { className: 'class1', progressClassName: 'class3' }); cy.get('.class1').should('exist'); cy.get('.class3').should('exist'); }); it('uses syntactic sugar for different notification type', () => { toast('default'); toast.success('success'); toast.error('error'); toast.warning('warning'); toast.info('info'); toast.warn('warn'); toast.dark('dark'); cy.resolveEntranceAnimation(); cy.findByText('default').should('exist'); cy.findByText('success').should('exist'); cy.findByText('error').should('exist'); cy.findByText('warning').should('exist'); cy.findByText('info').should('exist'); cy.findByText('warn').should('exist'); cy.findByText('dark').should('exist'); }); it('handle controlled progress bar', () => { const id = toast('msg', { progress: 0.3 }); cy.resolveEntranceAnimation(); cy.findByRole('progressbar') .should('have.attr', 'style') .and('include', 'scaleX(0.3)') .then(() => { toast.done(id); cy.findByRole('progressbar').should('have.attr', 'style').and('include', 'scaleX(1)'); }); }); it('handle rejected promise', () => { function rejectPromise() { return new Promise((_, reject) => { setTimeout(() => { reject(new Error('oops')); }, 2000); }); } toast.promise(rejectPromise, { pending: 'loading', error: { render(props) { return <>{props.data?.message}; } } }); cy.resolveEntranceAnimation(); cy.findByText('loading').should('exist'); cy.wait(2000); cy.findByText('loading').should('not.exist'); cy.findByText('oops').should('exist'); }); it('handle resolved promise', () => { function resolvePromise() { return new Promise((resolve, _) => { setTimeout(() => { resolve('it worked'); }, 2000); }); } toast.promise(resolvePromise, { pending: 'loading', success: { render(props) { return <>{props.data}; } } }); cy.resolveEntranceAnimation(); cy.findByText('loading').should('exist'); cy.wait(2000); cy.findByText('loading').should('not.exist'); cy.findByText('it worked').should('exist'); }); it('support onOpen and onClose callback', () => { const id = 'hello'; cy.mount( <> ); cy.findByRole('button', { name: 'display msg' }).click(); cy.get('@onOpen').should('have.been.calledOnce'); cy.findByRole('button', { name: 'remove' }).click(); cy.get('@onClose').should('have.been.calledOnce'); }); xit('remove all toasts', () => { cy.mount( <> ); cy.findByRole('button', { name: 'display msg' }).click(); cy.findByText('msg1').should('exist'); cy.findByRole('button', { name: 'remove' }).click(); cy.wait(2000); cy.findByText('msg1').should('not.exist'); }); }); describe.skip('with multi containers', () => { const Containers = { First: 'first', Second: 'second', Third: 'third' }; it('clear waiting queue for a given container', () => { cy.mount( <>
    ); cy.findByRole('button', { name: 'first' }).click(); cy.findByRole('button', { name: 'second' }).click(); cy.resolveEntranceAnimation(); cy.findByText('msg2-c1').should('not.exist'); cy.findByText('msg2-c2').should('not.exist'); cy.findByText('msg1-c1').should('exist'); cy.findByText('msg1-c2').should('exist'); cy.findByText('msg1-c1').then(() => { cy.findByRole('button', { name: 'clear' }).click(); cy.findByText('msg1-c1') .click() .then(() => { cy.resolveEntranceAnimation(); cy.findByText('msg1-c1').should('not.exist'); cy.findByText('msg2-c1').should('not.exist'); }); }); }); it('update a toast even when using multi containers', () => { const id = 'boo'; cy.mount( <> ); cy.findByRole('button', { name: 'notify' }).click(); cy.resolveEntranceAnimation(); cy.findByText('second container') .should('exist') .then(() => { cy.findByRole('button', { name: 'update' }).click(); cy.findByText('second container updated').should('exist'); }); }); xit('remove toast for a given container', () => { const toastId = '123'; cy.mount( <>
    ); cy.findByRole('button', { name: 'notify' }).click(); cy.resolveEntranceAnimation(); cy.findByText('second container') .should('exist') .then(() => { cy.findByRole('button', { name: 'clear' }).click(); cy.findByText('second container').should('not.exist'); }); }); xit('remove all toasts for a given container', () => { const toastId = '123'; cy.mount( <>
    ); cy.findByRole('button', { name: 'notify' }).click(); cy.resolveEntranceAnimation(); cy.findByText('first container').should('exist'); cy.findByText('third container second toast').should('exist'); cy.findByText('third container') .should('exist') .then(() => { cy.findByRole('button', { name: 'clear third' }).click(); cy.resolveEntranceAnimation(); cy.findByText('first container').should('exist'); cy.findByText('third container').should('not.exist'); cy.findByText('third container second toast').should('not.exist'); cy.findByText('first container') .should('exist') .then(() => { cy.findByRole('button', { name: 'clear non-existent' }).click(); cy.findByText('first container').should('not.exist'); cy.findByText('third container').should('not.exist'); }); }); }); describe('with limit', () => { beforeEach(() => { cy.mount(); }); it('limit the number of toast displayed', () => { toast('msg1'); toast('msg2'); toast('msg3'); cy.resolveEntranceAnimation(); cy.findByText('msg3').should('not.exist'); cy.findByText('msg1').should('exist'); cy.findByText('msg2') .should('exist') .click() .then(() => { cy.resolveEntranceAnimation(); cy.findByText('msg3').should('exist'); }); }); it('clear waiting queue', () => { toast('msg1'); toast('msg2'); toast('msg3'); cy.resolveEntranceAnimation(); cy.findByText('msg3').should('not.exist'); cy.findByText('msg1').should('exist'); cy.findByText('msg2') .should('exist') .then(() => { toast.clearWaitingQueue(); cy.findByText('msg2') .click() .then(() => { cy.resolveEntranceAnimation(); cy.findByText('msg3').should('not.exist'); }); }); }); }); }); describe('with stacked container', () => { it('render toasts', () => { cy.mount(); toast('hello 1'); toast('hello 2'); toast('hello 3'); cy.findByText('hello 1').should('exist').and('not.be.visible'); cy.findByText('hello 2').should('exist').and('not.be.visible'); cy.findByText('hello 3').should('exist').and('be.visible'); }); }); ================================================ FILE: src/core/toast.ts ================================================ import { ClearWaitingQueueFunc, Id, IdOpts, NotValidatedToastProps, OnChangeCallback, ToastContent, ToastOptions, ToastProps, TypeOptions, UpdateOptions } from '../types'; import { isFn, isNum, isStr, Type } from '../utils'; import { genToastId } from './genToastId'; import { clearWaitingQueue, getToast, isToastActive, onChange, pushToast, removeToast, toggleToast } from './store'; /** * Generate a toastId or use the one provided */ function getToastId(options?: ToastOptions) { return options && (isStr(options.toastId) || isNum(options.toastId)) ? options.toastId : genToastId(); } /** * If the container is not mounted, the toast is enqueued */ function dispatchToast(content: ToastContent, options: NotValidatedToastProps): Id { pushToast(content, options); return options.toastId; } /** * Merge provided options with the defaults settings and generate the toastId */ function mergeOptions(type: string, options?: ToastOptions) { return { ...options, type: (options && options.type) || type, toastId: getToastId(options) } as NotValidatedToastProps; } function createToastByType(type: string) { return (content: ToastContent, options?: ToastOptions) => dispatchToast(content, mergeOptions(type, options)); } function toast(content: ToastContent, options?: ToastOptions) { return dispatchToast(content, mergeOptions(Type.DEFAULT, options)); } toast.loading = (content: ToastContent, options?: ToastOptions) => dispatchToast( content, mergeOptions(Type.DEFAULT, { isLoading: true, autoClose: false, closeOnClick: false, closeButton: false, draggable: false, ...options }) ); export interface ToastPromiseParams { pending?: string | UpdateOptions; success?: string | UpdateOptions; error?: string | UpdateOptions; } function handlePromise( promise: Promise | (() => Promise), { pending, error, success }: ToastPromiseParams, options?: ToastOptions ) { let id: Id; if (pending) { id = isStr(pending) ? toast.loading(pending, options) : toast.loading(pending.render, { ...options, ...(pending as ToastOptions) } as ToastOptions); } const resetParams = { isLoading: null, autoClose: null, closeOnClick: null, closeButton: null, draggable: null }; const resolver = (type: TypeOptions, input: string | UpdateOptions | undefined, result: T) => { // Remove the toast if the input has not been provided. This prevents the toast from hanging // in the pending state if a success/error toast has not been provided. if (input == null) { toast.dismiss(id); return; } const baseParams = { type, ...resetParams, ...options, data: result }; const params = isStr(input) ? { render: input } : input; // if the id is set we know that it's an update if (id) { toast.update(id, { ...baseParams, ...params } as UpdateOptions); } else { // using toast.promise without loading toast(params!.render, { ...baseParams, ...params } as ToastOptions); } return result; }; const p = isFn(promise) ? promise() : promise; //call the resolvers only when needed p.then(result => resolver('success', success, result)).catch(err => resolver('error', error, err)); return p; } /** * Supply a promise or a function that return a promise and the notification will be updated if it resolves or fails. * When the promise is pending a spinner is displayed by default. * `toast.promise` returns the provided promise so you can chain it. * * Simple example: * * ``` * toast.promise(MyPromise, * { * pending: 'Promise is pending', * success: 'Promise resolved 👌', * error: 'Promise rejected 🤯' * } * ) * * ``` * * Advanced usage: * ``` * toast.promise<{name: string}, {message: string}, undefined>( * resolveWithSomeData, * { * pending: { * render: () => "I'm loading", * icon: false, * }, * success: { * render: ({data}) => `Hello ${data.name}`, * icon: "🟢", * }, * error: { * render({data}){ * // When the promise reject, data will contains the error * return * } * } * } * ) * ``` */ toast.promise = handlePromise; toast.success = createToastByType(Type.SUCCESS); toast.info = createToastByType(Type.INFO); toast.error = createToastByType(Type.ERROR); toast.warning = createToastByType(Type.WARNING); toast.warn = toast.warning; toast.dark = (content: ToastContent, options?: ToastOptions) => dispatchToast( content, mergeOptions(Type.DEFAULT, { theme: 'dark', ...options }) ); interface RemoveParams { id?: Id; containerId: Id; } function dismiss(params: RemoveParams): void; function dismiss(params?: Id): void; function dismiss(params?: Id | RemoveParams) { removeToast(params); } /** * Remove toast programmatically * * - Remove all toasts: * ``` * toast.dismiss() * ``` * * - Remove all toasts that belongs to a given container * ``` * toast.dismiss({ container: "123" }) * ``` * * - Remove toast that has a given id regardless the container * ``` * toast.dismiss({ id: "123" }) * ``` * * - Remove toast that has a given id for a specific container * ``` * toast.dismiss({ id: "123", containerId: "12" }) * ``` */ toast.dismiss = dismiss; /** * Clear waiting queue when limit is used */ toast.clearWaitingQueue = clearWaitingQueue as ClearWaitingQueueFunc; /** * Check if a toast is active * * - Check regardless the container * ``` * toast.isActive("123") * ``` * * - Check in a specific container * ``` * toast.isActive("123", "containerId") * ``` */ toast.isActive = isToastActive; /** * Update a toast, see https://fkhadra.github.io/react-toastify/update-toast/ for more * * Example: * ``` * // With a string * toast.update(toastId, { * render: "New content", * type: "info", * }); * * // Or with a component * toast.update(toastId, { * render: MyComponent * }); * * // Or a function * toast.update(toastId, { * render: () =>
    New content
    * }); * * // Apply a transition * toast.update(toastId, { * render: "New Content", * type: toast.TYPE.INFO, * transition: Rotate * }) * ``` */ toast.update = (toastId: Id, options: UpdateOptions = {}) => { const toast = getToast(toastId, options as ToastOptions); if (toast) { const { props: oldOptions, content: oldContent } = toast; const nextOptions = { delay: 100, ...oldOptions, ...options, toastId: options.toastId || toastId, updateId: genToastId() } as ToastProps & UpdateOptions; if (nextOptions.toastId !== toastId) nextOptions.staleId = toastId; const content = nextOptions.render || oldContent; delete nextOptions.render; dispatchToast(content, nextOptions); } }; /** * Used for controlled progress bar. It will automatically close the notification. * * If you don't want your notification to be clsoed when the timer is done you should use `toast.update` instead as follow instead: * * ``` * toast.update(id, { * progress: null, // remove controlled progress bar * render: "ok", * type: "success", * autoClose: 5000 // set autoClose to the desired value * }); * ``` */ toast.done = (id: Id) => { toast.update(id, { progress: 1 }); }; /** * Subscribe to change when a toast is added, removed and updated * * Usage: * ``` * const unsubscribe = toast.onChange((payload) => { * switch (payload.status) { * case "added": * // new toast added * break; * case "updated": * // toast updated * break; * case "removed": * // toast has been removed * break; * } * }) * ``` */ toast.onChange = onChange as (cb: OnChangeCallback) => () => void; /** * Play a toast(s) timer progammatically * * Usage: * * - Play all toasts * ``` * toast.play() * ``` * * - Play all toasts for a given container * ``` * toast.play({ containerId: "123" }) * ``` * * - Play toast that has a given id regardless the container * ``` * toast.play({ id: "123" }) * ``` * * - Play toast that has a given id for a specific container * ``` * toast.play({ id: "123", containerId: "12" }) * ``` */ toast.play = (opts?: IdOpts) => toggleToast(true, opts); /** * Pause a toast(s) timer progammatically * * Usage: * * - Pause all toasts * ``` * toast.pause() * ``` * * - Pause all toasts for a given container * ``` * toast.pause({ containerId: "123" }) * ``` * * - Pause toast that has a given id regardless the container * ``` * toast.pause({ id: "123" }) * ``` * * - Pause toast that has a given id for a specific container * ``` * toast.pause({ id: "123", containerId: "12" }) * ``` */ toast.pause = (opts?: IdOpts) => toggleToast(false, opts); export { toast }; ================================================ FILE: src/hooks/index.ts ================================================ export * from './useToastContainer'; export * from './useToast'; ================================================ FILE: src/hooks/useIsomorphicLayoutEffect.ts ================================================ import { useEffect, useLayoutEffect } from 'react'; export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; ================================================ FILE: src/hooks/useToast.ts ================================================ import { DOMAttributes, useEffect, useRef, useState } from 'react'; import { ToastProps } from '../types'; import { Default, Direction } from '../utils'; import { registerToggle } from '../core/store'; interface Draggable { start: number; delta: number; removalDistance: number; canCloseOnClick: boolean; canDrag: boolean; didMove: boolean; } export function useToast(props: ToastProps) { const [isRunning, setIsRunning] = useState(false); const [preventExitTransition, setPreventExitTransition] = useState(false); const toastRef = useRef(null); const drag = useRef({ start: 0, delta: 0, removalDistance: 0, canCloseOnClick: true, canDrag: false, didMove: false }).current; const { autoClose, pauseOnHover, closeToast, onClick, closeOnClick } = props; registerToggle({ id: props.toastId, containerId: props.containerId, fn: setIsRunning }); useEffect(() => { if (props.pauseOnFocusLoss) { bindFocusEvents(); return () => { unbindFocusEvents(); }; } }, [props.pauseOnFocusLoss]); function bindFocusEvents() { if (!document.hasFocus()) pauseToast(); window.addEventListener('focus', playToast); window.addEventListener('blur', pauseToast); } function unbindFocusEvents() { window.removeEventListener('focus', playToast); window.removeEventListener('blur', pauseToast); } function onDragStart(e: React.PointerEvent) { if (props.draggable === true || props.draggable === e.pointerType) { bindDragEvents(); const toast = toastRef.current!; drag.canCloseOnClick = true; drag.canDrag = true; toast.style.transition = 'none'; if (props.draggableDirection === Direction.X) { drag.start = e.clientX; drag.removalDistance = toast.offsetWidth * (props.draggablePercent / 100); } else { drag.start = e.clientY; drag.removalDistance = (toast.offsetHeight * (props.draggablePercent === Default.DRAGGABLE_PERCENT ? props.draggablePercent * 1.5 : props.draggablePercent)) / 100; } } } function onDragTransitionEnd(e: React.PointerEvent) { const { top, bottom, left, right } = toastRef.current!.getBoundingClientRect(); if ( e.nativeEvent.type !== 'touchend' && props.pauseOnHover && e.clientX >= left && e.clientX <= right && e.clientY >= top && e.clientY <= bottom ) { pauseToast(); } else { playToast(); } } function playToast() { setIsRunning(true); } function pauseToast() { setIsRunning(false); } function bindDragEvents() { drag.didMove = false; document.addEventListener('pointermove', onDragMove); document.addEventListener('pointerup', onDragEnd); } function unbindDragEvents() { document.removeEventListener('pointermove', onDragMove); document.removeEventListener('pointerup', onDragEnd); } function onDragMove(e: PointerEvent) { const toast = toastRef.current!; if (drag.canDrag && toast) { drag.didMove = true; if (isRunning) pauseToast(); if (props.draggableDirection === Direction.X) { drag.delta = e.clientX - drag.start; } else { drag.delta = e.clientY - drag.start; } // prevent false positive during a toast click if (drag.start !== e.clientX) drag.canCloseOnClick = false; const translate = props.draggableDirection === 'x' ? `${drag.delta}px, var(--y)` : `0, calc(${drag.delta}px + var(--y))`; toast.style.transform = `translate3d(${translate},0)`; toast.style.opacity = `${1 - Math.abs(drag.delta / drag.removalDistance)}`; } } function onDragEnd() { unbindDragEvents(); const toast = toastRef.current!; if (drag.canDrag && drag.didMove && toast) { drag.canDrag = false; if (Math.abs(drag.delta) > drag.removalDistance) { setPreventExitTransition(true); props.closeToast(true); props.collapseAll(); return; } toast.style.transition = 'transform 0.2s, opacity 0.2s'; toast.style.removeProperty('transform'); toast.style.removeProperty('opacity'); } } const eventHandlers: DOMAttributes = { onPointerDown: onDragStart, onPointerUp: onDragTransitionEnd }; if (autoClose && pauseOnHover) { eventHandlers.onMouseEnter = pauseToast; // progress control is delegated to the container if (!props.stacked) eventHandlers.onMouseLeave = playToast; } // prevent toast from closing when user drags the toast if (closeOnClick) { eventHandlers.onClick = (e: React.MouseEvent) => { onClick && onClick(e); drag.canCloseOnClick && closeToast(true); }; } return { playToast, pauseToast, isRunning, preventExitTransition, toastRef, eventHandlers }; } ================================================ FILE: src/hooks/useToastContainer.ts ================================================ import { useRef, useSyncExternalStore } from 'react'; import { isToastActive, registerContainer } from '../core/store'; import { Toast, ToastContainerProps, ToastPosition } from '../types'; export function useToastContainer(props: ToastContainerProps) { const { subscribe, getSnapshot, setProps } = useRef(registerContainer(props)).current; setProps(props); const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)?.slice(); function getToastToRender(cb: (position: ToastPosition, toastList: Toast[]) => T) { if (!snapshot) return []; const toRender = new Map(); if (props.newestOnTop) snapshot.reverse(); snapshot.forEach(toast => { const { position } = toast.props; toRender.has(position) || toRender.set(position, []); toRender.get(position)!.push(toast); }); return Array.from(toRender, p => cb(p[0], p[1])); } return { getToastToRender, isToastActive, count: snapshot?.length }; } ================================================ FILE: src/index.ts ================================================ import './style.css'; export { cssTransition, collapseToast } from './utils'; export { ToastContainer, Bounce, Flip, Slide, Zoom, Icons } from './components'; export type { IconProps, CloseButton } from './components'; export type { ToastPromiseParams } from './core'; export { toast } from './core'; export type { TypeOptions, Theme, ToastPosition, ToastContentProps, ToastContent, ToastTransition, ToastClassName, ClearWaitingQueueParams, DraggableDirection, ToastOptions, UpdateOptions, ToastContainerProps, ToastTransitionProps, Id, ToastItem, ClearWaitingQueueFunc, OnChangeCallback, ToastIcon } from './types'; export type { CloseButtonProps } from './components/CloseButton'; ================================================ FILE: src/style.css ================================================ :root { --toastify-color-light: #fff; --toastify-color-dark: #121212; --toastify-color-info: #3498db; --toastify-color-success: #07bc0c; --toastify-color-warning: #f1c40f; --toastify-color-error: hsl(6, 78%, 57%); --toastify-color-transparent: rgba(255, 255, 255, 0.7); --toastify-icon-color-info: var(--toastify-color-info); --toastify-icon-color-success: var(--toastify-color-success); --toastify-icon-color-warning: var(--toastify-color-warning); --toastify-icon-color-error: var(--toastify-color-error); --toastify-container-width: fit-content; --toastify-toast-width: 320px; --toastify-toast-offset: 16px; --toastify-toast-top: max(var(--toastify-toast-offset), env(safe-area-inset-top)); --toastify-toast-right: max(var(--toastify-toast-offset), env(safe-area-inset-right)); --toastify-toast-left: max(var(--toastify-toast-offset), env(safe-area-inset-left)); --toastify-toast-bottom: max(var(--toastify-toast-offset), env(safe-area-inset-bottom)); --toastify-toast-background: #fff; --toastify-toast-padding: 14px; --toastify-toast-min-height: 64px; --toastify-toast-max-height: 800px; --toastify-toast-bd-radius: 6px; --toastify-toast-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); --toastify-font-family: sans-serif; --toastify-z-index: 9999; --toastify-text-color-light: #757575; --toastify-text-color-dark: #fff; /* Used only for colored theme */ --toastify-text-color-info: #fff; --toastify-text-color-success: #fff; --toastify-text-color-warning: #fff; --toastify-text-color-error: #fff; --toastify-spinner-color: #616161; --toastify-spinner-color-empty-area: #e0e0e0; --toastify-color-progress-light: linear-gradient(to right, #4cd964, #5ac8fa, #007aff, #34aadc, #5856d6, #ff2d55); --toastify-color-progress-dark: #bb86fc; --toastify-color-progress-info: var(--toastify-color-info); --toastify-color-progress-success: var(--toastify-color-success); --toastify-color-progress-warning: var(--toastify-color-warning); --toastify-color-progress-error: var(--toastify-color-error); /* used to control the opacity of the progress trail */ --toastify-color-progress-bgo: 0.2; } .Toastify__toast-container { z-index: var(--toastify-z-index); -webkit-transform: translate3d(0, 0, var(--toastify-z-index)); position: fixed; width: var(--toastify-container-width); box-sizing: border-box; color: #fff; display: flex; flex-direction: column; } .Toastify__toast-container--top-left { top: var(--toastify-toast-top); left: var(--toastify-toast-left); } .Toastify__toast-container--top-center { top: var(--toastify-toast-top); left: 50%; transform: translateX(-50%); align-items: center; } .Toastify__toast-container--top-right { top: var(--toastify-toast-top); right: var(--toastify-toast-right); align-items: end; } .Toastify__toast-container--bottom-left { bottom: var(--toastify-toast-bottom); left: var(--toastify-toast-left); } .Toastify__toast-container--bottom-center { bottom: var(--toastify-toast-bottom); left: 50%; transform: translateX(-50%); align-items: center; } .Toastify__toast-container--bottom-right { bottom: var(--toastify-toast-bottom); right: var(--toastify-toast-right); align-items: end; } .Toastify__toast { --y: 0; position: relative; touch-action: none; width: var(--toastify-toast-width); min-height: var(--toastify-toast-min-height); box-sizing: border-box; margin-bottom: 1rem; padding: var(--toastify-toast-padding); border-radius: var(--toastify-toast-bd-radius); box-shadow: var(--toastify-toast-shadow); max-height: var(--toastify-toast-max-height); font-family: var(--toastify-font-family); /* webkit only issue #791 */ z-index: 0; /* inner swag */ display: flex; flex: 1 auto; align-items: center; word-break: break-word; } @media only screen and (max-width: 480px) { .Toastify__toast-container { width: 100vw; left: env(safe-area-inset-left); margin: 0; } .Toastify__toast-container--top-left, .Toastify__toast-container--top-center, .Toastify__toast-container--top-right { top: env(safe-area-inset-top); transform: translateX(0); } .Toastify__toast-container--bottom-left, .Toastify__toast-container--bottom-center, .Toastify__toast-container--bottom-right { bottom: env(safe-area-inset-bottom); transform: translateX(0); } .Toastify__toast-container--rtl { right: env(safe-area-inset-right); left: initial; } .Toastify__toast { --toastify-toast-width: 100%; margin-bottom: 0; border-radius: 0; } } .Toastify__toast-container[data-stacked='true'] { width: var(--toastify-toast-width); } .Toastify__toast--stacked { position: absolute; width: 100%; transform: translate3d(0, var(--y), 0) scale(var(--s)); transition: transform 0.3s; } .Toastify__toast--stacked[data-collapsed] .Toastify__toast-body, .Toastify__toast--stacked[data-collapsed] .Toastify__close-button { transition: opacity 0.1s; } .Toastify__toast--stacked[data-collapsed='false'] { overflow: visible; } .Toastify__toast--stacked[data-collapsed='true']:not(:last-child) > * { opacity: 0; } .Toastify__toast--stacked:after { content: ''; position: absolute; left: 0; right: 0; height: calc(var(--g) * 1px); bottom: 100%; } .Toastify__toast--stacked[data-pos='top'] { top: 0; } .Toastify__toast--stacked[data-pos='bot'] { bottom: 0; } .Toastify__toast--stacked[data-pos='bot'].Toastify__toast--stacked:before { transform-origin: top; } .Toastify__toast--stacked[data-pos='top'].Toastify__toast--stacked:before { transform-origin: bottom; } .Toastify__toast--stacked:before { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 100%; transform: scaleY(3); z-index: -1; } .Toastify__toast--rtl { direction: rtl; } .Toastify__toast--close-on-click { cursor: pointer; } .Toastify__toast-icon { margin-inline-end: 10px; width: 22px; flex-shrink: 0; display: flex; } .Toastify--animate { animation-fill-mode: both; animation-duration: 0.5s; } .Toastify--animate-icon { animation-fill-mode: both; animation-duration: 0.3s; } .Toastify__toast-theme--dark { background: var(--toastify-color-dark); color: var(--toastify-text-color-dark); } .Toastify__toast-theme--light { background: var(--toastify-color-light); color: var(--toastify-text-color-light); } .Toastify__toast-theme--colored.Toastify__toast--default { background: var(--toastify-color-light); color: var(--toastify-text-color-light); } .Toastify__toast-theme--colored.Toastify__toast--info { color: var(--toastify-text-color-info); background: var(--toastify-color-info); } .Toastify__toast-theme--colored.Toastify__toast--success { color: var(--toastify-text-color-success); background: var(--toastify-color-success); } .Toastify__toast-theme--colored.Toastify__toast--warning { color: var(--toastify-text-color-warning); background: var(--toastify-color-warning); } .Toastify__toast-theme--colored.Toastify__toast--error { color: var(--toastify-text-color-error); background: var(--toastify-color-error); } .Toastify__progress-bar-theme--light { background: var(--toastify-color-progress-light); } .Toastify__progress-bar-theme--dark { background: var(--toastify-color-progress-dark); } .Toastify__progress-bar--info { background: var(--toastify-color-progress-info); } .Toastify__progress-bar--success { background: var(--toastify-color-progress-success); } .Toastify__progress-bar--warning { background: var(--toastify-color-progress-warning); } .Toastify__progress-bar--error { background: var(--toastify-color-progress-error); } .Toastify__progress-bar-theme--colored.Toastify__progress-bar--info, .Toastify__progress-bar-theme--colored.Toastify__progress-bar--success, .Toastify__progress-bar-theme--colored.Toastify__progress-bar--warning, .Toastify__progress-bar-theme--colored.Toastify__progress-bar--error { background: var(--toastify-color-transparent); } .Toastify__close-button { color: #fff; position: absolute; top: 6px; right: 6px; background: transparent; outline: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: 0.3s ease; z-index: 1; } .Toastify__toast--rtl .Toastify__close-button { left: 6px; right: unset; } .Toastify__close-button--light { color: #000; opacity: 0.3; } .Toastify__close-button > svg { fill: currentColor; height: 16px; width: 14px; } .Toastify__close-button:hover, .Toastify__close-button:focus { opacity: 1; } @keyframes Toastify__trackProgress { 0% { transform: scaleX(1); } 100% { transform: scaleX(0); } } .Toastify__progress-bar { position: absolute; bottom: 0; left: 0; width: 100%; height: 100%; z-index: 1; opacity: 0.7; transform-origin: left; } .Toastify__progress-bar--animated { animation: Toastify__trackProgress linear 1 forwards; } .Toastify__progress-bar--controlled { transition: transform 0.2s; } .Toastify__progress-bar--rtl { right: 0; left: initial; transform-origin: right; border-bottom-left-radius: initial; } .Toastify__progress-bar--wrp { position: absolute; overflow: hidden; bottom: 0; left: 0; width: 100%; height: 5px; border-bottom-left-radius: var(--toastify-toast-bd-radius); border-bottom-right-radius: var(--toastify-toast-bd-radius); } .Toastify__progress-bar--wrp[data-hidden='true'] { opacity: 0; } .Toastify__progress-bar--bg { opacity: var(--toastify-color-progress-bgo); width: 100%; height: 100%; } .Toastify__spinner { width: 20px; height: 20px; box-sizing: border-box; border: 2px solid; border-radius: 100%; border-color: var(--toastify-spinner-color-empty-area); border-right-color: var(--toastify-spinner-color); animation: Toastify__spin 0.65s linear infinite; } @keyframes Toastify__bounceInRight { from, 60%, 75%, 90%, to { animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } from { opacity: 0; transform: translate3d(3000px, 0, 0); } 60% { opacity: 1; transform: translate3d(-25px, 0, 0); } 75% { transform: translate3d(10px, 0, 0); } 90% { transform: translate3d(-5px, 0, 0); } to { transform: none; } } @keyframes Toastify__bounceOutRight { 20% { opacity: 1; transform: translate3d(-20px, var(--y), 0); } to { opacity: 0; transform: translate3d(2000px, var(--y), 0); } } @keyframes Toastify__bounceInLeft { from, 60%, 75%, 90%, to { animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 0% { opacity: 0; transform: translate3d(-3000px, 0, 0); } 60% { opacity: 1; transform: translate3d(25px, 0, 0); } 75% { transform: translate3d(-10px, 0, 0); } 90% { transform: translate3d(5px, 0, 0); } to { transform: none; } } @keyframes Toastify__bounceOutLeft { 20% { opacity: 1; transform: translate3d(20px, var(--y), 0); } to { opacity: 0; transform: translate3d(-2000px, var(--y), 0); } } @keyframes Toastify__bounceInUp { from, 60%, 75%, 90%, to { animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } from { opacity: 0; transform: translate3d(0, 3000px, 0); } 60% { opacity: 1; transform: translate3d(0, -20px, 0); } 75% { transform: translate3d(0, 10px, 0); } 90% { transform: translate3d(0, -5px, 0); } to { transform: translate3d(0, 0, 0); } } @keyframes Toastify__bounceOutUp { 20% { transform: translate3d(0, calc(var(--y) - 10px), 0); } 40%, 45% { opacity: 1; transform: translate3d(0, calc(var(--y) + 20px), 0); } to { opacity: 0; transform: translate3d(0, -2000px, 0); } } @keyframes Toastify__bounceInDown { from, 60%, 75%, 90%, to { animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); } 0% { opacity: 0; transform: translate3d(0, -3000px, 0); } 60% { opacity: 1; transform: translate3d(0, 25px, 0); } 75% { transform: translate3d(0, -10px, 0); } 90% { transform: translate3d(0, 5px, 0); } to { transform: none; } } @keyframes Toastify__bounceOutDown { 20% { transform: translate3d(0, calc(var(--y) - 10px), 0); } 40%, 45% { opacity: 1; transform: translate3d(0, calc(var(--y) + 20px), 0); } to { opacity: 0; transform: translate3d(0, 2000px, 0); } } .Toastify__bounce-enter--top-left, .Toastify__bounce-enter--bottom-left { animation-name: Toastify__bounceInLeft; } .Toastify__bounce-enter--top-right, .Toastify__bounce-enter--bottom-right { animation-name: Toastify__bounceInRight; } .Toastify__bounce-enter--top-center { animation-name: Toastify__bounceInDown; } .Toastify__bounce-enter--bottom-center { animation-name: Toastify__bounceInUp; } .Toastify__bounce-exit--top-left, .Toastify__bounce-exit--bottom-left { animation-name: Toastify__bounceOutLeft; } .Toastify__bounce-exit--top-right, .Toastify__bounce-exit--bottom-right { animation-name: Toastify__bounceOutRight; } .Toastify__bounce-exit--top-center { animation-name: Toastify__bounceOutUp; } .Toastify__bounce-exit--bottom-center { animation-name: Toastify__bounceOutDown; } @keyframes Toastify__zoomIn { from { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); } 50% { opacity: 1; } } @keyframes Toastify__zoomOut { from { opacity: 1; } 50% { opacity: 0; transform: translate3d(0, var(--y), 0) scale3d(0.3, 0.3, 0.3); } to { opacity: 0; } } .Toastify__zoom-enter { animation-name: Toastify__zoomIn; } .Toastify__zoom-exit { animation-name: Toastify__zoomOut; } @keyframes Toastify__flipIn { from { transform: perspective(400px) rotate3d(1, 0, 0, 90deg); animation-timing-function: ease-in; opacity: 0; } 40% { transform: perspective(400px) rotate3d(1, 0, 0, -20deg); animation-timing-function: ease-in; } 60% { transform: perspective(400px) rotate3d(1, 0, 0, 10deg); opacity: 1; } 80% { transform: perspective(400px) rotate3d(1, 0, 0, -5deg); } to { transform: perspective(400px); } } @keyframes Toastify__flipOut { from { transform: translate3d(0, var(--y), 0) perspective(400px); } 30% { transform: translate3d(0, var(--y), 0) perspective(400px) rotate3d(1, 0, 0, -20deg); opacity: 1; } to { transform: translate3d(0, var(--y), 0) perspective(400px) rotate3d(1, 0, 0, 90deg); opacity: 0; } } .Toastify__flip-enter { animation-name: Toastify__flipIn; } .Toastify__flip-exit { animation-name: Toastify__flipOut; } @keyframes Toastify__slideInRight { from { transform: translate3d(110%, 0, 0); visibility: visible; } to { transform: translate3d(0, var(--y), 0); } } @keyframes Toastify__slideInLeft { from { transform: translate3d(-110%, 0, 0); visibility: visible; } to { transform: translate3d(0, var(--y), 0); } } @keyframes Toastify__slideInUp { from { transform: translate3d(0, 110%, 0); visibility: visible; } to { transform: translate3d(0, var(--y), 0); } } @keyframes Toastify__slideInDown { from { transform: translate3d(0, -110%, 0); visibility: visible; } to { transform: translate3d(0, var(--y), 0); } } @keyframes Toastify__slideOutRight { from { transform: translate3d(0, var(--y), 0); } to { visibility: hidden; transform: translate3d(110%, var(--y), 0); } } @keyframes Toastify__slideOutLeft { from { transform: translate3d(0, var(--y), 0); } to { visibility: hidden; transform: translate3d(-110%, var(--y), 0); } } @keyframes Toastify__slideOutDown { from { transform: translate3d(0, var(--y), 0); } to { visibility: hidden; transform: translate3d(0, 500px, 0); } } @keyframes Toastify__slideOutUp { from { transform: translate3d(0, var(--y), 0); } to { visibility: hidden; transform: translate3d(0, -500px, 0); } } .Toastify__slide-enter--top-left, .Toastify__slide-enter--bottom-left { animation-name: Toastify__slideInLeft; } .Toastify__slide-enter--top-right, .Toastify__slide-enter--bottom-right { animation-name: Toastify__slideInRight; } .Toastify__slide-enter--top-center { animation-name: Toastify__slideInDown; } .Toastify__slide-enter--bottom-center { animation-name: Toastify__slideInUp; } .Toastify__slide-exit--top-left, .Toastify__slide-exit--bottom-left { animation-name: Toastify__slideOutLeft; animation-timing-function: ease-in; animation-duration: 0.3s; } .Toastify__slide-exit--top-right, .Toastify__slide-exit--bottom-right { animation-name: Toastify__slideOutRight; animation-timing-function: ease-in; animation-duration: 0.3s; } .Toastify__slide-exit--top-center { animation-name: Toastify__slideOutUp; animation-timing-function: ease-in; animation-duration: 0.3s; } .Toastify__slide-exit--bottom-center { animation-name: Toastify__slideOutDown; animation-timing-function: ease-in; animation-duration: 0.3s; } @keyframes Toastify__spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } ================================================ FILE: src/tests.cy.tsx ================================================ import React from 'react'; import { ToastContainer } from './components'; import { toast } from './core'; import { ToastContentProps } from './types'; it('allows to specify the reason when calling closeToast', () => { const onCloseFunc = cy.stub().as('onCloseFunc'); function CustomNotification({ closeToast }: ToastContentProps) { return ( ); } cy.mount(
    ); cy.findByRole('button', { name: 'notify' }).click(); cy.findByRole('alert').should('exist'); cy.findByRole('button', { name: 'closeme' }).click(); cy.get('@onCloseFunc').should('have.been.calledWith', 'foobar'); }); it('focus notification when alt+t is pressed', () => { cy.mount(
    ); cy.findByRole('button', { name: 'notify' }).click(); cy.resolveEntranceAnimation(); cy.findByRole('alert').should('exist'); cy.get('body').type('{alt+t}'); cy.focused().should('have.attr', 'role', 'alert').and('have.attr', 'aria-label', 'notification'); }); ================================================ FILE: src/types.ts ================================================ import React, { HTMLAttributes } from 'react'; import { CloseButtonProps, IconProps } from './components'; import { clearWaitingQueue } from './core/store'; type Nullable = { [P in keyof T]: T[P] | null; }; export type TypeOptions = 'info' | 'success' | 'warning' | 'error' | 'default'; export type Theme = 'light' | 'dark' | 'colored' | (string & {}); export type ToastPosition = 'top-right' | 'top-center' | 'top-left' | 'bottom-right' | 'bottom-center' | 'bottom-left'; export type CloseToastFunc = ((reason?: boolean | string) => void) & ((e: React.MouseEvent) => void); export interface ToastContentProps { closeToast: CloseToastFunc; toastProps: ToastProps; isPaused: boolean; data: Data; } export type ToastContent = React.ReactNode | ((props: ToastContentProps) => React.ReactNode); export type ToastIcon = false | ((props: IconProps) => React.ReactNode) | React.ReactElement; export type Id = number | string; export type ToastTransition = React.FC | React.ComponentClass; /** * ClassName for the elements - can take a function to build a classname or a raw string that is cx'ed to defaults */ export type ToastClassName = | ((context?: { type?: TypeOptions; defaultClassName?: string; position?: ToastPosition; rtl?: boolean }) => string) | string; export interface ClearWaitingQueueParams { containerId?: Id; } export type DraggableDirection = 'x' | 'y'; interface CommonOptions { /** * Pause the timer when the mouse hover the toast. * `Default: true` */ pauseOnHover?: boolean; /** * Pause the toast when the window loses focus. * `Default: true` */ pauseOnFocusLoss?: boolean; /** * Remove the toast when clicked. * `Default: false` */ closeOnClick?: boolean; /** * Set the delay in ms to close the toast automatically. * Use `false` to prevent the toast from closing. * `Default: 5000` */ autoClose?: number | false; /** * Set the default position to use. * `One of: 'top-right', 'top-center', 'top-left', 'bottom-right', 'bottom-center', 'bottom-left'` * `Default: 'top-right'` */ position?: ToastPosition; /** * Pass a custom close button. * To remove the close button pass `false` */ closeButton?: boolean | ((props: CloseButtonProps) => React.ReactNode) | React.ReactElement; /** * An optional css class to set for the progress bar. */ progressClassName?: ToastClassName; /** * Hide or show the progress bar. * `Default: false` */ hideProgressBar?: boolean; /** * Pass a custom transition see https://fkhadra.github.io/react-toastify/custom-animation/ */ transition?: ToastTransition; /** * Allow toast to be draggable * `Default: 'touch'` */ draggable?: boolean | 'mouse' | 'touch'; /** * The percentage of the toast's width it takes for a drag to dismiss a toast * `Default: 80` */ draggablePercent?: number; /** * Specify in which direction should you swipe to dismiss the toast * `Default: "x"` */ draggableDirection?: DraggableDirection; /** * Define the ARIA role for the toast * `Default: alert` * https://www.w3.org/WAI/PF/aria/roles */ role?: string; /** * Set id to handle multiple container */ containerId?: Id; /** * Fired when clicking inside toaster */ onClick?: (event: React.MouseEvent) => void; /** * Support right to left display. * `Default: false` */ rtl?: boolean; /** * Used to display a custom icon. Set it to `false` to prevent * the icons from being displayed */ icon?: ToastIcon; /** * Theme to use. * `One of: 'light', 'dark', 'colored'` * `Default: 'light'` */ theme?: Theme; /** * When set to `true` the built-in progress bar won't be rendered at all. Autoclose delay won't have any effect as well * This is only used when you want to replace the progress bar with your own. * * See https://stackblitz.com/edit/react-toastify-custom-progress-bar?file=src%2FApp.tsx for an example. */ customProgressBar?: boolean; } export interface ToastOptions extends CommonOptions { /** * An optional css class to set. */ className?: ToastClassName; /** * Called when toast is mounted. */ onOpen?: () => void; /** * Called when toast is unmounted. * The callback first argument is the closure reason. * It is "true" when the notification is closed by a user action like clicking on the close button. */ onClose?: (reason?: boolean | string) => void; /** * An optional inline style to apply. */ style?: React.CSSProperties; /** * Set the toast type. * `One of: 'info', 'success', 'warning', 'error', 'default'` */ type?: TypeOptions; /** * Set a custom `toastId` */ toastId?: Id; /** * Used during update */ updateId?: Id; /** * Set the percentage for the controlled progress bar. `Value must be between 0 and 1.` */ progress?: number; /** * Let you provide any data, useful when you are using your own component */ data?: Data; /** * Let you specify the aria-label */ ariaLabel?: string; /** * Add a delay in ms before the toast appear. */ delay?: number; isLoading?: boolean; } export interface UpdateOptions extends Nullable> { /** * Used to update a toast. * Pass any valid ReactNode(string, number, component) */ render?: ToastContent; } export interface ToastContainerProps extends CommonOptions, Pick, 'aria-label'> { /** * An optional css class to set. */ className?: ToastClassName; /** * Will stack the toast with the newest on the top. */ stacked?: boolean; /** * Whether or not to display the newest toast on top. * `Default: false` */ newestOnTop?: boolean; /** * An optional inline style to apply. */ style?: React.CSSProperties; /** * An optional inline style to apply for the toast. */ toastStyle?: React.CSSProperties; /** * An optional css class for the toast. */ toastClassName?: ToastClassName; /** * Limit the number of toast displayed at the same time */ limit?: number; /** * Shortcut to focus the first notification with the keyboard * `default: Alt+t` * * ``` * // focus when user presses ⌘ + F * const matchShortcut = (e: KeyboardEvent) => e.metaKey && e.key === 'f' * ``` */ hotKeys?: (e: KeyboardEvent) => boolean; } export interface ToastTransitionProps { isIn: boolean; done: () => void; position: ToastPosition | string; preventExitTransition: boolean; nodeRef: React.RefObject; children?: React.ReactNode; playToast(): void; } /** * @INTERNAL */ export interface ToastProps extends ToastOptions { isIn: boolean; staleId?: Id; toastId: Id; key: Id; transition: ToastTransition; closeToast: CloseToastFunc; position: ToastPosition; children?: ToastContent; draggablePercent: number; draggableDirection?: DraggableDirection; progressClassName?: ToastClassName; className?: ToastClassName; deleteToast: () => void; theme: Theme; type: TypeOptions; collapseAll: () => void; stacked?: boolean; } /** * @INTERNAL */ export interface NotValidatedToastProps extends Partial { toastId: Id; } /** * @INTERNAL */ export interface Toast { content: ToastContent; props: ToastProps; toggle?: (v: boolean) => void; removalReason?: true | undefined; isActive: boolean; staleId?: Id; } export type ToastItemStatus = 'added' | 'removed' | 'updated'; export interface ToastItem { content: ToastContent; id: Id; theme?: Theme; type?: TypeOptions; isLoading?: boolean; containerId?: Id; data: Data; icon?: ToastIcon; status: ToastItemStatus; reason?: boolean | string; } export type OnChangeCallback = (toast: ToastItem) => void; export type IdOpts = { id?: Id; containerId?: Id; }; export type ClearWaitingQueueFunc = typeof clearWaitingQueue; ================================================ FILE: src/utils/collapseToast.ts ================================================ import { Default } from './constant'; /** * Used to collapse toast after exit animation */ export function collapseToast(node: HTMLElement, done: () => void, duration = Default.COLLAPSE_DURATION) { const { scrollHeight, style } = node; requestAnimationFrame(() => { style.minHeight = 'initial'; style.height = scrollHeight + 'px'; style.transition = `all ${duration}ms`; requestAnimationFrame(() => { style.height = '0'; style.padding = '0'; style.margin = '0'; setTimeout(done, duration as number); }); }); } ================================================ FILE: src/utils/constant.ts ================================================ export const enum Type { INFO = 'info', SUCCESS = 'success', WARNING = 'warning', ERROR = 'error', DEFAULT = 'default' } export const enum Default { COLLAPSE_DURATION = 300, DEBOUNCE_DURATION = 50, CSS_NAMESPACE = 'Toastify', DRAGGABLE_PERCENT = 80, CONTAINER_ID = 1 } export const enum Direction { X = 'x', Y = 'y' } ================================================ FILE: src/utils/cssTransition.tsx ================================================ import React, { useEffect, useLayoutEffect, useRef } from 'react'; import { collapseToast } from './collapseToast'; import { Default } from './constant'; import { ToastTransitionProps } from '../types'; export interface CSSTransitionProps { /** * Css class to apply when toast enter */ enter: string; /** * Css class to apply when toast leave */ exit: string; /** * Append current toast position to the classname. * If multiple classes are provided, only the last one will get the position * For instance `myclass--top-center`... * `Default: false` */ appendPosition?: boolean; /** * Collapse toast smoothly when exit animation end * `Default: true` */ collapse?: boolean; /** * Collapse transition duration * `Default: 300` */ collapseDuration?: number; } const enum AnimationStep { Enter, Exit } /** * Css animation that just work. * You could use animate.css for instance * * * ``` * cssTransition({ * enter: "animate__animated animate__bounceIn", * exit: "animate__animated animate__bounceOut" * }) * ``` * */ export function cssTransition({ enter, exit, appendPosition = false, collapse = true, collapseDuration = Default.COLLAPSE_DURATION }: CSSTransitionProps) { return function ToastTransition({ children, position, preventExitTransition, done, nodeRef, isIn, playToast }: ToastTransitionProps) { const enterClassName = appendPosition ? `${enter}--${position}` : enter; const exitClassName = appendPosition ? `${exit}--${position}` : exit; const animationStep = useRef(AnimationStep.Enter); useLayoutEffect(() => { const node = nodeRef.current!; const classToToken = enterClassName.split(' '); const onEntered = (e: AnimationEvent) => { if (e.target !== nodeRef.current) return; playToast(); node.removeEventListener('animationend', onEntered); node.removeEventListener('animationcancel', onEntered); if (animationStep.current === AnimationStep.Enter && e.type !== 'animationcancel') { node.classList.remove(...classToToken); } }; const onEnter = () => { node.classList.add(...classToToken); node.addEventListener('animationend', onEntered); node.addEventListener('animationcancel', onEntered); }; onEnter(); }, []); useEffect(() => { const node = nodeRef.current!; const onExited = () => { node.removeEventListener('animationend', onExited); collapse ? collapseToast(node, done, collapseDuration) : done(); }; const onExit = () => { animationStep.current = AnimationStep.Exit; node.className += ` ${exitClassName}`; node.addEventListener('animationend', onExited); }; if (!isIn) preventExitTransition ? onExited() : onExit(); }, [isIn]); return <>{children}; }; } ================================================ FILE: src/utils/index.ts ================================================ export * from './propValidator'; export * from './constant'; export * from './cssTransition'; export * from './collapseToast'; export * from './mapper'; ================================================ FILE: src/utils/mapper.ts ================================================ import { Toast, ToastContentProps, ToastItem, ToastItemStatus, ToastProps } from '../types'; import { cloneElement, isValidElement, ReactElement } from 'react'; import { isFn, isStr } from './propValidator'; export function toToastItem(toast: Toast, status: ToastItemStatus): ToastItem { return { content: renderContent(toast.content, toast.props), containerId: toast.props.containerId, id: toast.props.toastId, theme: toast.props.theme, type: toast.props.type, data: toast.props.data || {}, isLoading: toast.props.isLoading, icon: toast.props.icon, reason: toast.removalReason, status }; } export function renderContent(content: unknown, props: ToastProps, isPaused: boolean = false) { if (isValidElement(content) && !isStr(content.type)) { return cloneElement(content as ReactElement, { closeToast: props.closeToast, toastProps: props, data: props.data, isPaused }); } else if (isFn(content)) { return content({ closeToast: props.closeToast, toastProps: props, data: props.data, isPaused }); } return content; } ================================================ FILE: src/utils/propValidator.ts ================================================ import { isValidElement } from 'react'; import { Id } from '../types'; export const isNum = (v: any): v is Number => typeof v === 'number' && !isNaN(v); export const isStr = (v: any): v is String => typeof v === 'string'; export const isFn = (v: any): v is Function => typeof v === 'function'; export const isId = (v: unknown): v is Id => isStr(v) || isNum(v); export const parseClassName = (v: any) => (isStr(v) || isFn(v) ? v : null); export const getAutoCloseDelay = (toastAutoClose?: false | number, containerAutoClose?: false | number) => toastAutoClose === false || (isNum(toastAutoClose) && toastAutoClose > 0) ? toastAutoClose : containerAutoClose; export const canBeRendered = (content: T): boolean => isValidElement(content) || isStr(content) || isFn(content) || isNum(content); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "jsx": "react", "moduleResolution": "node", "esModuleInterop": true, "lib": ["es2015", "dom"] } } ================================================ FILE: tsup.config.ts ================================================ import { defineConfig, Options } from 'tsup'; const injectFunc = ` function injectStyle(css) { if (!css || typeof document === 'undefined') return const head = document.head || document.getElementsByTagName('head')[0] const style = document.createElement('style') style.type = 'text/css' if(head.firstChild) { head.insertBefore(style, head.firstChild) } else { head.appendChild(style) } if(style.styleSheet) { style.styleSheet.cssText = css } else { style.appendChild(document.createTextNode(css)) } } `; const baseConfig: Options = { minify: true, target: 'es2018', sourcemap: true, dts: true, format: ['esm', 'cjs'], injectStyle: css => { return `${injectFunc}injectStyle(${css});`; }, banner: { js: '"use client";' } }; export default defineConfig([ { ...baseConfig, entry: ['src/index.ts'], external: ['react'], clean: ['dist'] }, { ...baseConfig, injectStyle: false, entry: { unstyled: 'src/index.ts' }, external: ['react'], clean: ['dist'] }, { ...baseConfig, entry: { 'use-notification-center/index': 'src/addons/use-notification-center/index.ts' }, external: ['react', 'react-toastify'], clean: ['addons'], outDir: 'addons' } ]); ================================================ FILE: vite.config.mts ================================================ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import istanbul from 'vite-plugin-istanbul'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [ react(), istanbul({ cypress: true, requireEnv: false }) ] });