├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── setupTests.ts ├── src ├── NotificationContainer │ ├── NotificationContainer.test.tsx │ ├── NotificationContainer.tsx │ └── styles.css └── index.tsx └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .cache 4 | .DS_Store 5 | coverage 6 | build 7 | .history -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alex Permyakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple-React-Notifications 2 | 3 | Tiny library (only 1kb gzip) that allows you to add notifications to your app. 4 | We don't want to blow up our bundle size because of notifications, right? 5 | 6 | ## Demo 7 | 8 | https://alexpermyakov.github.io/simple-react-notifications/ 9 | 10 | Despite the small size, it supports: 11 | 12 | - [Rendering success and error default notifications](#rendering-success-and-error-default-notifications) 13 | - [Rendering user defined component](#rendering-user-defined-component) 14 | - [Positioning](#positioning) 15 | - [Configuring all in one place](#configuring-all-in-one-place) 16 | - [Animation](#animation) 17 | - [Remove notification items programmatically](#remove-notification-items-programmatically) 18 | 19 | ## Installation 20 | 21 | ``` 22 | $ npm install simple-react-notifications 23 | $ yarn add simple-react-notifications 24 | ``` 25 | 26 | ## Usage 27 | 28 | ### Rendering success and error default notifications 29 | 30 | Notifier has a few built-in components for displaying an error or a successfull operation: 31 | 32 | ```javascript 33 | import React from "react"; 34 | import notifier from "simple-react-notifications"; 35 | import "simple-react-notifications/dist/index.css"; 36 | 37 | const App = () => ( 38 |
39 | 47 |
48 | ); 49 | ``` 50 | 51 | ### Rendering user defined component 52 | 53 | The real power comes with rendering our own component. In this case it's not even a notification, just a view with real data: 54 | 55 | ```javascript 56 | const RouteInfo = ({ header, onClosePanel }) => ( 57 |
69 |

{header}

70 |

Bicycle 2.4 km, 8 min.

71 |

Use caution - may involve errors or sections not suited for bicycling

72 | 78 |
79 | ); 80 | ``` 81 | 82 | It completely up to us the way we add styles. We can use styled-components or whatever we like. The notify() method will just render it: 83 | 84 | ```javascript 85 | const App = () => ( 86 |
87 | 102 |
103 | ); 104 | ``` 105 | 106 | As you can see here, render() receives onClose callback, which we have to pass inside our component in order to close the notification when user click on the button. 107 | 108 | ### Positioning 109 | 110 | By default, all items will be positioned in the top right corner. The following values are allowed: top-right, top-center, top-left, bottom-right, bottom-center, bottom-left. 111 | 112 | ```javascript 113 | const App = () => ( 114 |
115 | 136 |
137 | ); 138 | ``` 139 | 140 | ### Configuring all in one place 141 | 142 | Instead of specifing all params again and again for each item, we can put it in one place: 143 | 144 | ```javascript 145 | notifier.configure({ 146 | autoClose: 2000, 147 | position: "top-center", 148 | delay: 500, 149 | single: false, 150 | width: "480px" 151 | }); 152 | 153 | const App = () => ( 154 |
155 | 162 |
163 | ); 164 | ``` 165 | 166 | Params in notifier function will override their default values in configure(). 167 | 168 | ### Animation 169 | 170 | First, define the css-animation somewhere in your .css file: 171 | 172 | ```css 173 | @keyframes fadeIn { 174 | from { 175 | opacity: 0; 176 | } 177 | to { 178 | opacity: 1; 179 | } 180 | } 181 | 182 | @keyframes fadeOut { 183 | from { 184 | opacity: 1; 185 | } 186 | to { 187 | opacity: 0; 188 | } 189 | } 190 | ``` 191 | 192 | Second, specify it during the notifier() call or in configure(): 193 | 194 | ```javascript 195 | notifier.configure({ 196 | position: "top-center", 197 | animation: { 198 | in: "fadeIn", // try to comment it out 199 | out: "fadeOut", 200 | duration: 600 // overriding the default(300ms) value 201 | } 202 | }); 203 | 204 | const App = () => ( 205 |
206 | 213 |
214 | ); 215 | ``` 216 | 217 | You can specify only in or out params as well. 218 | 219 | ### Remove notification items programmatically 220 | 221 | ```javascript 222 | import React from "react"; 223 | import notifier from "simple-react-notifications"; 224 | 225 | notifier.configure({ 226 | render: ({ id, onClose }) => ( 227 | 232 | ) 233 | }); 234 | 235 | class App extends React.Component { 236 | id = null; 237 | 238 | render() { 239 | return ( 240 |
241 | 242 | 243 | 244 |
245 | ); 246 | } 247 | } 248 | ``` 249 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-react-notifications", 3 | "version": "1.2.15", 4 | "description": "Tiny react.js notification library (1kb gzip).", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "jsnext:main": "dist/index.es.js", 8 | "types": "dist/index.d.ts", 9 | "files": [ 10 | "dist" 11 | ], 12 | "scripts": { 13 | "build": "rollup -c", 14 | "test": "jest" 15 | }, 16 | "jest": { 17 | "roots": [ 18 | "./src" 19 | ], 20 | "moduleNameMapper": { 21 | "\\.(css|less)$": "identity-obj-proxy" 22 | }, 23 | "collectCoverage": true, 24 | "transform": { 25 | "^.+\\.tsx?$": "ts-jest" 26 | }, 27 | "setupFilesAfterEnv": [ 28 | "./setupTests.ts" 29 | ], 30 | "snapshotSerializers": [ 31 | "enzyme-to-json/serializer" 32 | ] 33 | }, 34 | "keywords": [ 35 | "react", 36 | "javascript", 37 | "notification", 38 | "react-component", 39 | "success", 40 | "error", 41 | "tiny" 42 | ], 43 | "author": "Alex Permyakov ", 44 | "license": "MIT", 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/alexpermyakov/simple-react-notifications.git" 48 | }, 49 | "homepage": "https://alexpermyakov.github.io/simple-react-notifications", 50 | "devDependencies": { 51 | "@types/enzyme": "^3.9.4", 52 | "@types/enzyme-adapter-react-16": "^1.0.5", 53 | "@types/jest": "^24.0.15", 54 | "@types/react": "^16.8.22", 55 | "@types/react-dom": "^16.8.4", 56 | "enzyme": "^3.10.0", 57 | "enzyme-adapter-react-16": "^1.14.0", 58 | "enzyme-to-json": "^3.3.5", 59 | "identity-obj-proxy": "^3.0.0", 60 | "jest": "^24.8.0", 61 | "react": "^16.8.6", 62 | "react-dom": "^16.8.6", 63 | "react-scripts-ts": "^3.1.0", 64 | "react-test-renderer": "^16.8.6", 65 | "rollup": "^1.16.2", 66 | "rollup-plugin-bundle-size": "^1.0.3", 67 | "rollup-plugin-commonjs": "^10.0.0", 68 | "rollup-plugin-node-resolve": "^5.1.0", 69 | "rollup-plugin-peer-deps-external": "^2.2.0", 70 | "rollup-plugin-postcss": "^2.0.3", 71 | "rollup-plugin-terser": "^5.0.0", 72 | "rollup-plugin-typescript2": "^0.21.2", 73 | "ts-jest": "^24.0.2", 74 | "typescript": "^3.5.2" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "rollup-plugin-typescript2"; 2 | import commonjs from "rollup-plugin-commonjs"; 3 | import external from "rollup-plugin-peer-deps-external"; 4 | import resolve from "rollup-plugin-node-resolve"; 5 | import bundleSize from "rollup-plugin-bundle-size"; 6 | import postcss from "rollup-plugin-postcss"; 7 | import { terser } from "rollup-plugin-terser"; 8 | import pkg from "./package.json"; 9 | 10 | export default { 11 | input: "src/index.tsx", 12 | output: [ 13 | { 14 | file: pkg.main, 15 | format: "cjs", 16 | exports: "named", 17 | sourcemap: true 18 | }, 19 | { 20 | file: pkg.module, 21 | format: "es", 22 | exports: "named", 23 | sourcemap: true 24 | } 25 | ], 26 | external: ["react", "react-dom"], 27 | plugins: [ 28 | external(), 29 | resolve(), 30 | typescript({ 31 | rollupCommonJSResolveHack: true, 32 | exclude: "**/__tests__/**", 33 | clean: true 34 | }), 35 | commonjs({ 36 | include: ["node_modules/**"], 37 | namedExports: { 38 | "node_modules/react/react.js": [ 39 | "Children", 40 | "Component", 41 | "PropTypes", 42 | "createElement" 43 | ], 44 | "node_modules/react-dom/index.js": ["render"] 45 | } 46 | }), 47 | postcss({ 48 | extract: true 49 | }), 50 | terser(), 51 | bundleSize() 52 | ] 53 | }; 54 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import EnzymeAdapter from "enzyme-adapter-react-16"; 3 | configure({ adapter: new EnzymeAdapter() }); 4 | -------------------------------------------------------------------------------- /src/NotificationContainer/NotificationContainer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow, mount } from "enzyme"; 3 | import NotificationContainer from "./NotificationContainer"; 4 | 5 | describe("", () => { 6 | it("should render correctly with default props", () => { 7 | const wrapper = shallow( 8 | 9 | ); 10 | expect(wrapper).toMatchSnapshot(); 11 | }); 12 | 13 | it("should render multiple items", () => { 14 | const wrapper = mount( 15 | 16 | ); 17 | wrapper.setProps({ message: "Done! Check your email.", id:2, cleared:jest.fn }); 18 | wrapper.setProps({ message: "Dark theme is enabled", id:3, cleared:jest.fn }); 19 | wrapper.update(); 20 | expect(wrapper.find(".info").length).toEqual(2); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/NotificationContainer/NotificationContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import "./styles.css"; 3 | 4 | type Animation = { 5 | in?: string; 6 | out?: string; 7 | duration?: number; 8 | timingFunction?: string; 9 | }; 10 | 11 | export interface RenderProps { 12 | id: number; 13 | onClose: () => void; 14 | onMouseEnter: () => void; 15 | onMouseLeave: () => void; 16 | } 17 | 18 | export type Config = { 19 | message?: string; 20 | type?: string; 21 | position?: string; 22 | autoClose?: number; 23 | delay?: number; 24 | render?: (props: RenderProps) => any; 25 | onlyLast?: boolean; 26 | width?: string; 27 | animation?: Animation; 28 | newestOnTop?: boolean; 29 | closeOnClick?: boolean; 30 | pauseOnHover?: boolean; 31 | rtl?: boolean; 32 | }; 33 | 34 | type EventArg = { 35 | id: number; 36 | callback: () => void; 37 | }; 38 | 39 | export const eventManager = { 40 | ids: [] as EventArg[], 41 | add: function(id: number, callback: () => void) { 42 | this.ids.push({ id, callback }); 43 | }, 44 | remove: function(id?: number) { 45 | if (id) { 46 | const { callback } = this.ids.find((it: any) => it.id === id)!; 47 | callback(); 48 | this.ids = this.ids.filter((it: any) => it.id !== id); 49 | } else { 50 | this.ids.forEach((it: any) => it.callback()); 51 | this.ids = []; 52 | } 53 | } 54 | }; 55 | 56 | const filter = (ar: JSX.Element[], id: number) => 57 | ar.filter((it: JSX.Element) => it.key != id); 58 | 59 | const Notification = ({ 60 | message, 61 | onClose, 62 | type = "info", 63 | width = "300px", 64 | rtl, 65 | closeOnClick, 66 | onMouseEnter, 67 | onMouseLeave 68 | }: any) => ( 69 |
closeOnClick && onClose()} 73 | onMouseEnter={onMouseEnter} 74 | onMouseLeave={onMouseLeave} 75 | > 76 | {message} 77 | 78 |
79 | ); 80 | 81 | 82 | const timers: {[id: number]: Timer} = {}; 83 | 84 | class Timer { 85 | public remaining: number; 86 | public resume: () => void; 87 | public pause: () => void; 88 | 89 | constructor(callback: Function, delay: number) { 90 | let timerId = -1, 91 | start = 0; 92 | this.remaining = delay; 93 | 94 | this.pause = () => { 95 | clearTimeout(timerId); 96 | this.remaining -= Date.now() - start; 97 | }; 98 | 99 | this.resume = () => { 100 | start = Date.now(); 101 | clearTimeout(timerId); 102 | timerId = setTimeout(callback, this.remaining); 103 | }; 104 | 105 | this.resume(); 106 | } 107 | } 108 | 109 | export default (props: Config & { id: number; cleared: () => void }) => { 110 | const [, setItems] = useState([] as JSX.Element[]); 111 | const [hovered, setHovered] = useState(false); 112 | const [dismissedByClick, setDismissedByClick] = useState(false); 113 | const items = useRef([] as JSX.Element[]); 114 | const { autoClose = 3000, delay = 0, id } = props; 115 | const { animation = {} } = props; 116 | const animationDuration = animation.duration || 300; 117 | const closeTime = autoClose > 0 ? autoClose : 0; 118 | 119 | const removeItemById = (id: number) => { 120 | items.current = filter(items.current, id); 121 | setItems(items.current); 122 | items.current.length === 0 && props.cleared(); 123 | }; 124 | 125 | useEffect(() => { 126 | const index = items.current.findIndex((it: JSX.Element) => it.key == id); 127 | const el = items.current[index]; 128 | if (!el) { 129 | return; 130 | } 131 | 132 | const t = timers[id].remaining; 133 | const style = 134 | hovered && !dismissedByClick 135 | ? {} 136 | : { 137 | animationName: `${animation.out}`, 138 | animationDelay: `${t - animationDuration}ms`, 139 | animationDuration: `${animationDuration}ms` 140 | }; 141 | 142 | const cloned = React.cloneElement(el, { 143 | ...el.props, 144 | style 145 | }); 146 | 147 | items.current.splice(index, 1, cloned); 148 | setItems([...items.current]); 149 | }, [hovered, dismissedByClick]); 150 | 151 | useEffect(() => { 152 | const params = { 153 | id, 154 | onClose: () => { 155 | setTimeout(() => removeItemById(id), animationDuration); 156 | timers[id].remaining = animationDuration; 157 | setDismissedByClick(true); 158 | }, 159 | onMouseEnter: () => f("pause"), 160 | onMouseLeave: () => f("resume") 161 | }; 162 | 163 | const f = (action: string) => { 164 | props.pauseOnHover && timers[id] && timers[id][action](); 165 | setHovered(action === "pause"); 166 | }; 167 | 168 | let newItem = props.render ? ( 169 | props.render(params) 170 | ) : ( 171 | 172 | ); 173 | 174 | if (animationDuration) { 175 | newItem = ( 176 |
184 | {newItem} 185 |
186 | ); 187 | } 188 | 189 | const rest = props.onlyLast ? [] : items.current; 190 | const { newestOnTop = true } = props; 191 | items.current = newestOnTop ? [newItem, ...rest] : [...rest, newItem]; 192 | eventManager.add(id, () => removeItemById(id)); 193 | 194 | setTimeout(() => setItems(items.current), delay); 195 | 196 | timers[id] = new Timer( 197 | () => autoClose && removeItemById(id), 198 | delay + closeTime + animationDuration 199 | ); 200 | }, [props]); 201 | 202 | return <>{...items.current}; 203 | }; 204 | -------------------------------------------------------------------------------- /src/NotificationContainer/styles.css: -------------------------------------------------------------------------------- 1 | .simple-react-notifier { 2 | position: fixed; 3 | top: 0; 4 | right: 0; 5 | z-index: 9999; 6 | pointer-events: none; 7 | padding: 16px; 8 | } 9 | 10 | .simple-react-notifier > * { 11 | pointer-events: initial; 12 | animation-fill-mode: forwards; 13 | } 14 | 15 | .simple-react-notifier.top-left { 16 | left: 0; 17 | right: auto; 18 | } 19 | 20 | .simple-react-notifier.top-center { 21 | left: 50%; 22 | transform: translate(-50%, 0); 23 | right: auto; 24 | } 25 | 26 | .simple-react-notifier.bottom-left { 27 | top: auto; 28 | right: auto; 29 | left: 0; 30 | bottom: 0; 31 | } 32 | 33 | .simple-react-notifier.bottom-center { 34 | top: auto; 35 | left: 50%; 36 | transform: translate(-50%, 0); 37 | right: auto; 38 | bottom: 0; 39 | } 40 | 41 | .simple-react-notifier.bottom-right { 42 | top: auto; 43 | bottom: 0; 44 | } 45 | 46 | .simple-react-notifier .item { 47 | position: relative; 48 | min-height: 48px; 49 | margin-bottom: 16px; 50 | padding: 14px 6px; 51 | border-radius: 4px; 52 | box-shadow: 1px 3px 4px rgba(0, 0, 0, 0.2); 53 | display: flex; 54 | align-items: flex-start; 55 | cursor: default; 56 | font-size: 14px; 57 | line-height: 1.3; 58 | color: white; 59 | } 60 | 61 | .simple-react-notifier .item span { 62 | margin: 0 20px 0 10px; 63 | } 64 | 65 | .simple-react-notifier .item button { 66 | cursor: pointer; 67 | color: white; 68 | background: transparent; 69 | border: 0; 70 | position: relative; 71 | top: -5px; 72 | } 73 | 74 | .simple-react-notifier .item.success { 75 | background: #28a745; 76 | border-left: 8px solid #1e7532; 77 | } 78 | 79 | .simple-react-notifier .item.info { 80 | background: #077bf7; 81 | border-left: 8px solid #055fbe; 82 | } 83 | 84 | .simple-react-notifier .item.error { 85 | background: #e23849; 86 | border-left: 8px solid #ac1f2d; 87 | } 88 | 89 | .simple-react-notifier .item.warn { 90 | background: #ffd9bc; 91 | border-left: 8px solid #ffb366; 92 | } 93 | 94 | .simple-react-notifier .item.rtl { 95 | direction: rtl; 96 | border-left: 0; 97 | } 98 | 99 | .simple-react-notifier .item.rtl.success { 100 | border-right: 8px solid #1e7532; 101 | } 102 | 103 | .simple-react-notifier .item.rtl.info { 104 | border-right: 8px solid #055fbe; 105 | } 106 | 107 | .simple-react-notifier .item.rtl.error { 108 | border-right: 8px solid #ac1f2d; 109 | } 110 | 111 | .simple-react-notifier .item.rtl.warn { 112 | border-right: 8px solid #ffb366; 113 | } 114 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import NotificationContainer, { 4 | eventManager, 5 | Config 6 | } from "./NotificationContainer/NotificationContainer"; 7 | 8 | type NotifierBase = (cfg?: Config) => number; 9 | 10 | interface Notifier extends NotifierBase { 11 | info: (message: string) => number; 12 | error: (message: string) => number; 13 | success: (message: string) => number; 14 | warn: (message: string) => number; 15 | configure: (cfg: Config) => void; 16 | dismiss: (id?: number) => void; 17 | } 18 | 19 | const cls = "simple-react-notifier"; 20 | 21 | let globalCfg: Config; 22 | let id = 0; 23 | 24 | const notifier: Notifier = (cfg) => { 25 | cfg = { ...(globalCfg || {}), ...cfg }; 26 | const { position = "top-right" } = cfg; 27 | let modalRoot = document.querySelector("." + cls + "." + position); 28 | if (!modalRoot) { 29 | modalRoot = document.createElement("div"); 30 | modalRoot.classList.add(cls, position); 31 | (modalRoot as HTMLElement).style.direction = cfg.rtl ? "rtl" : "ltr"; 32 | document.body.appendChild(modalRoot); 33 | } 34 | 35 | render( 36 | { 40 | try { 41 | document.body.removeChild(modalRoot!); 42 | } catch (e) {} 43 | }} 44 | />, 45 | modalRoot 46 | ); 47 | 48 | id++; 49 | return id - 1; 50 | }; 51 | 52 | notifier.info = message => notifier({ message, type: "info" }); 53 | notifier.success = message => notifier({ message, type: "success" }); 54 | notifier.error = message => notifier({ message, type: "error" }); 55 | notifier.warn = message => notifier({ message, type: "warn" }); 56 | notifier.configure = (cfg) => { 57 | globalCfg = cfg; 58 | }; 59 | notifier.dismiss = (id) => eventManager.remove(id); 60 | 61 | export default notifier; 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": ["dom", "es2017"], 5 | "jsx": "react", 6 | "outDir": "dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "noImplicitReturns": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitThis": false, 14 | "strictNullChecks": true, 15 | "typeRoots": ["node_modules/@types"], 16 | "types": ["node", "jest"], 17 | "allowSyntheticDefaultImports": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "declaration": true 20 | }, 21 | "include": ["./src/**/*"] 22 | } 23 | --------------------------------------------------------------------------------