├── .github ├── FUNDING.yml ├── workflows │ ├── stale.yml │ ├── node.js.yml │ └── docs.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE.md ├── src ├── params.ts ├── constants │ └── index.ts ├── utils │ ├── emitter.ts │ ├── timer.ts │ ├── index.ts │ └── parser.ts ├── shims-vue.d.ts ├── global-extensions.d.ts ├── index.ts ├── defaults.ts ├── notify.ts ├── auto-import-resolver.ts ├── plugin.ts ├── types.ts └── components │ ├── Notifications.css │ └── Notifications.tsx ├── env.d.ts ├── .gitignore ├── dist ├── auto-import-resolver │ ├── index.d.ts │ ├── index.es.js │ └── index.umd.js ├── index.umd.js ├── index.d.ts └── index.es.js ├── docs ├── index.md ├── .vitepress │ ├── theme │ │ └── index.mts │ └── config.mts ├── guide │ ├── installation.md │ ├── typescript.md │ ├── usage.md │ └── customization.md ├── api │ └── index.md └── components │ └── Demo.vue ├── .npmignore ├── vetur ├── tags.json └── attributes.json ├── vite.resolver.config.js ├── auto-import-resolver └── index.ts ├── LICENSE ├── vite.config.js ├── package.json ├── .eslintrc.cjs ├── test └── unit │ └── specs │ ├── util.spec.ts │ └── Notifications.spec.ts ├── tsconfig.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: kyvg 2 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Changes in PR: 2 | 3 | -------------------------------------------------------------------------------- /src/params.ts: -------------------------------------------------------------------------------- 1 | export const params = new Map(); 2 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export const COMPONENT_NAME = 'Notifications'; 2 | -------------------------------------------------------------------------------- /src/utils/emitter.ts: -------------------------------------------------------------------------------- 1 | import mitt from 'mitt'; 2 | 3 | type EventType = { 4 | add: NotificationOptions; 5 | close: unknown; 6 | } 7 | 8 | export const emitter = mitt(); 9 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import { DefineComponent } from 'vue'; 3 | const component: DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /src/global-extensions.d.ts: -------------------------------------------------------------------------------- 1 | import { notify } from './index'; 2 | 3 | declare module 'vue' { 4 | export interface ComponentCustomProperties { 5 | $notify: typeof notify; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly TEST: boolean 5 | } 6 | 7 | 8 | interface ImportMeta { 9 | readonly env: ImportMetaEnv 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | npm-debug.log 4 | yarn-error.log 5 | test/unit/coverage 6 | *.map 7 | .vscode 8 | temp/ 9 | dist/src/ 10 | .temp 11 | docs/.vitepress/dist 12 | docs/.vitepress/cache 13 | -------------------------------------------------------------------------------- /dist/auto-import-resolver/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ComponentResolverFunction } from 'unplugin-vue-components'; 2 | declare const autoImportResolver: (name?: string) => ComponentResolverFunction; 3 | export default autoImportResolver; 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | 4 | 5 | --- 6 | 9 | 10 | # Vue 3 notification library 💬 11 | -------------------------------------------------------------------------------- /dist/auto-import-resolver/index.es.js: -------------------------------------------------------------------------------- 1 | const e = "@kyvg/vue3-notification", o = "Notifications", r = (t = o) => (n) => { 2 | if (t === n) 3 | return { 4 | from: e, 5 | as: t, 6 | name: o 7 | }; 8 | }; 9 | export { 10 | r as default 11 | }; 12 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo/ 2 | src/ 3 | docs/ 4 | test/ 5 | temp/ 6 | npm-debug.log 7 | yarn-error.log 8 | dist/src/ 9 | .github 10 | .package-lock.json 11 | *.log 12 | .api-extractor 13 | .eslintrc 14 | .gitignore 15 | .eslintrc.js 16 | api-extractor.json 17 | tsconfig.json 18 | *.config.js 19 | PULL_REQUEST_TEMPLATE.md 20 | -------------------------------------------------------------------------------- /dist/auto-import-resolver/index.umd.js: -------------------------------------------------------------------------------- 1 | (function(o,e){typeof exports=="object"&&typeof module<"u"?module.exports=e():typeof define=="function"&&define.amd?define(e):(o=typeof globalThis<"u"?globalThis:o||self,o.resolver=e())})(this,function(){"use strict";const o="@kyvg/vue3-notification",e="Notifications";return(t=e)=>n=>{if(t===n)return{from:o,as:t,name:e}}}); 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { Plugin } from 'vue'; 2 | import { install } from './plugin'; 3 | export { notify, useNotification } from './notify'; 4 | export type { NotificationsOptions, NotificationsPluginOptions, NotificationItem } from './types'; 5 | export { default as Notifications } from './components/Notifications'; 6 | 7 | export default { 8 | install, 9 | } as Plugin; 10 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.mts: -------------------------------------------------------------------------------- 1 | import type { Theme } from 'vitepress' 2 | import DefaultTheme from 'vitepress/theme' 3 | import Demo from "../../components/Demo.vue" 4 | import Notification from "../../../dist/index.es" 5 | 6 | export default { 7 | extends: DefaultTheme, 8 | enhanceApp({ app }) { 9 | app.use(Notification); 10 | app.component('Demo', Demo) 11 | } 12 | } satisfies Theme -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | position: ['top', 'right'], 3 | cssAnimation: 'vn-fade', 4 | velocityAnimation: { 5 | enter: (el: Element) => { 6 | const height = el.clientHeight; 7 | 8 | return { 9 | height: [height, 0], 10 | opacity: [1, 0], 11 | }; 12 | }, 13 | leave: { 14 | height: 0, 15 | opacity: [0, 1], 16 | }, 17 | }, 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /vetur/tags.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "notifications": { 4 | "attributes": [ 5 | "group", 6 | "width", 7 | "reverse", 8 | "position", 9 | "classes", 10 | "animation-type", 11 | "animation", 12 | "animation-name", 13 | "speed", 14 | "duration", 15 | "delay", 16 | "max", 17 | "ignore-duplicates", 18 | "close-on-click", 19 | "pause-on-hover" 20 | ], 21 | "description": "Component that renders notifications group" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/stale@v1 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | stale-issue-message: 'Stale issue message' 17 | stale-pr-message: 'Stale pull request message' 18 | stale-issue-label: 'no-issue-activity' 19 | stale-pr-label: 'no-pr-activity' 20 | -------------------------------------------------------------------------------- /src/notify.ts: -------------------------------------------------------------------------------- 1 | import { emitter } from '@/utils/emitter'; 2 | import { NotificationsOptions } from './types'; 3 | 4 | export const notify = (args: NotificationsOptions | string): void => { 5 | if (typeof args === 'string') { 6 | args = { title: '', text: args }; 7 | } 8 | 9 | if (typeof args === 'object') { 10 | emitter.emit('add', args); 11 | } 12 | }; 13 | 14 | notify.close = (id: unknown): void => { 15 | emitter.emit('close', id); 16 | }; 17 | 18 | export const useNotification = () => { 19 | return { notify }; 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /vite.resolver.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | import dts from 'vite-plugin-dts'; 4 | 5 | export default defineConfig({ 6 | build: { 7 | lib: { 8 | name: 'resolver', 9 | entry: path.resolve(__dirname, './auto-import-resolver/index.ts'), 10 | fileName: (type) => `index.${type}.js`, 11 | }, 12 | emptyOutDir: false, 13 | outDir: 'dist/auto-import-resolver', 14 | }, 15 | plugins: [ 16 | dts({ 17 | entryRoot: './auto-import-resolver', 18 | }), 19 | ], 20 | }); 21 | -------------------------------------------------------------------------------- /src/auto-import-resolver.ts: -------------------------------------------------------------------------------- 1 | import { name as packageName } from '../package.json'; 2 | import type { ComponentResolverFunction } from 'unplugin-vue-components'; 3 | import { COMPONENT_NAME } from './constants'; 4 | 5 | const autoImportResolver = (name = COMPONENT_NAME): ComponentResolverFunction => { 6 | return (componentName) => { 7 | if (name === componentName) { 8 | return { 9 | from: packageName, 10 | as: name, 11 | name: COMPONENT_NAME, 12 | }; 13 | } 14 | }; 15 | }; 16 | 17 | export default autoImportResolver; 18 | -------------------------------------------------------------------------------- /auto-import-resolver/index.ts: -------------------------------------------------------------------------------- 1 | import { name as packageName } from '../package.json'; 2 | import type { ComponentResolverFunction } from 'unplugin-vue-components'; 3 | import { COMPONENT_NAME } from '../src/constants'; 4 | 5 | const autoImportResolver = (name = COMPONENT_NAME): ComponentResolverFunction => { 6 | return (componentName) => { 7 | if (name === componentName) { 8 | return { 9 | from: packageName, 10 | as: name, 11 | name: COMPONENT_NAME, 12 | }; 13 | } 14 | }; 15 | }; 16 | 17 | export default autoImportResolver; 18 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | 2 | import type { App } from 'vue'; 3 | import Notifications from '@/components/Notifications'; 4 | import { params } from '@/params'; 5 | import type { NotificationsPluginOptions } from '@/types'; 6 | import { notify } from '@/notify'; 7 | import { COMPONENT_NAME } from '@/constants'; 8 | 9 | export function install(app: App, args: NotificationsPluginOptions = {}): void { 10 | Object.entries(args).forEach((entry) => params.set(...entry)); 11 | const name = args.name || 'notify'; 12 | 13 | app.config.globalProperties['$' + name] = notify; 14 | app.component(args.componentName || COMPONENT_NAME, Notifications); 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | type NotificationType = 'warn' | 'success' | 'error' | (string & {}); 2 | 3 | export interface NotificationsOptions { 4 | id?: number | string; 5 | title?: string; 6 | text?: string; 7 | type?: NotificationType; 8 | group?: string; 9 | duration?: number; 10 | speed?: number; 11 | data?: unknown; 12 | clean?: boolean; 13 | clear?: boolean; 14 | ignoreDuplicates?: boolean; 15 | closeOnClick?: boolean; 16 | } 17 | 18 | export interface NotificationsPluginOptions { 19 | name?: string; 20 | componentName?: string; 21 | velocity?: any; 22 | } 23 | 24 | export type NotificationItem = Pick & { 25 | length: number; 26 | duplicates: number; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/timer.ts: -------------------------------------------------------------------------------- 1 | import { NotificationItem } from '@/types'; 2 | 3 | interface Timer { 4 | start: () => void; 5 | stop: () => void; 6 | } 7 | 8 | 9 | export type NotificationItemWithTimer = NotificationItem & { 10 | timer?: Timer; 11 | } 12 | 13 | export const createTimer = (callback: () => void, delay: number): Timer => { 14 | let timer: number; 15 | let startTime: number; 16 | let remainingTime = delay; 17 | 18 | const start = () => { 19 | startTime = Date.now(); 20 | timer = setTimeout(callback, remainingTime); 21 | }; 22 | 23 | const stop = () => { 24 | clearTimeout(timer); 25 | remainingTime -= Date.now() - startTime; 26 | }; 27 | 28 | start(); 29 | 30 | return { 31 | start, 32 | stop, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /docs/guide/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | aside: false 3 | title: 'Installation' 4 | --- 5 | 6 | # Installation 7 | 8 | - **Step 1**: Install `@kyvg/vue3-notification` locally 9 | ::: code-group 10 | 11 | ```bash [npm] 12 | npm install --save @kyvg/vue3-notification 13 | ``` 14 | 15 | ```bash [yarn] 16 | yarn add @kyvg/vue3-notification 17 | ``` 18 | 19 | ::: 20 | 21 | - **Step 2**: Add dependencies to your `main.js`: 22 | 23 | ```javascript 24 | import { createApp } from 'vue' 25 | import Notifications from '@kyvg/vue3-notification' 26 | 27 | const app = createApp() 28 | app.use(Notifications) 29 | ``` 30 | 31 | - **Step 3**: Add the global component to your `App.vue`: 32 | 33 | ```vue 34 | 39 | ``` 40 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | test: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Test on Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Evgeny Kirpichyov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vueJsx from '@vitejs/plugin-vue-jsx'; 3 | import path from 'path'; 4 | import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; 5 | import dts from 'vite-plugin-dts'; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | vueJsx(), 10 | cssInjectedByJsPlugin({ useStrictCSP: true }), 11 | dts({ rollupTypes: true }), 12 | ], 13 | build: { 14 | lib: { 15 | name: 'notifications', 16 | entry: path.resolve(__dirname, './src/index.ts'), 17 | fileName: (type) => `index.${type}.js`, 18 | }, 19 | emptyOutDir: true, 20 | rollupOptions: { 21 | external: ['vue'], 22 | output: { 23 | exports: 'named', 24 | globals: { 25 | vue: 'Vue', 26 | }, 27 | assetFileNames: assetInfo => { 28 | if (assetInfo.name === 'style.css') { 29 | return 'index.css'; 30 | } 31 | return assetInfo.name; 32 | }, 33 | }, 34 | }, 35 | }, 36 | resolve: { 37 | alias: { 38 | '@': path.resolve(__dirname, './src'), 39 | }, 40 | }, 41 | test: { 42 | environment: 'jsdom', 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /docs/.vitepress/config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: "Vue.js Notification", 6 | base: '/vue3-notification/', 7 | head: [ 8 | ['script', 9 | { type: 'module', src: "https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js" } 10 | ] 11 | ], 12 | themeConfig: { 13 | nav: [ 14 | { text: 'Demo', link: '/' }, 15 | { text: 'Get started', link: '/guide/installation' }, 16 | { text: "API Reference", link: "/api/" }, 17 | ], 18 | sidebar: { 19 | '/guide/': [ 20 | { text: 'Get started', items: [ 21 | { text: 'Installation', link: '/guide/installation' }, 22 | { text: 'Usage', link: '/guide/usage' }, 23 | { text: 'Customization', link: '/guide/customization' }, 24 | { text: 'TypeScript Support', link: '/guide/typescript'} 25 | ]}, 26 | ] 27 | }, 28 | socialLinks: [ 29 | { icon: 'github', link: 'https://github.com/kyvg/vue3-notification' } 30 | ], 31 | editLink: { 32 | pattern: 'https://github.com/kyvg/vue3-notification/edit/master/docs/:path', 33 | text: 'Edit this page on GitHub' 34 | } 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /src/components/Notifications.css: -------------------------------------------------------------------------------- 1 | .vue-notification-group { 2 | display: block; 3 | position: fixed; 4 | z-index: 5000; 5 | } 6 | 7 | .vue-notification-wrapper { 8 | display: block; 9 | overflow: hidden; 10 | width: 100%; 11 | margin: 0; 12 | padding: 0; 13 | } 14 | 15 | .notification-title { 16 | font-weight: 600; 17 | } 18 | 19 | .vue-notification-template { 20 | display: block; 21 | box-sizing: border-box; 22 | background: white; 23 | text-align: left; 24 | } 25 | 26 | .vue-notification { 27 | display: block; 28 | box-sizing: border-box; 29 | text-align: left; 30 | font-size: 12px; 31 | padding: 10px; 32 | margin: 0 5px 5px; 33 | 34 | color: white; 35 | background: #44A4FC; 36 | border-left: 5px solid #187FE7; 37 | } 38 | 39 | .vue-notification.warn { 40 | background: #ffb648; 41 | border-left-color: #f48a06; 42 | } 43 | 44 | .vue-notification.error { 45 | background: #E54D42; 46 | border-left-color: #B82E24; 47 | } 48 | 49 | .vue-notification.success { 50 | background: #68CD86; 51 | border-left-color: #42A85F; 52 | } 53 | 54 | .vn-fade-enter-active, .vn-fade-leave-active, .vn-fade-move { 55 | transition: all .5s; 56 | } 57 | 58 | .vn-fade-enter-from, .vn-fade-leave-to { 59 | opacity: 0; 60 | } -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './emitter'; 2 | export * from './parser'; 3 | 4 | interface Direction { 5 | x: null | string; 6 | y: null | string; 7 | } 8 | 9 | const directions = { 10 | x: new Set(['left', 'center', 'right']), 11 | y: new Set(['top', 'bottom']), 12 | }; 13 | 14 | /** 15 | * Sequential ID generator 16 | */ 17 | export const Id = (i => () => i++)(0); 18 | 19 | /** 20 | * Splits space/tab separated string into array and cleans empty string items. 21 | */ 22 | export const split = (value: unknown): string[] => { 23 | if (typeof value !== 'string') { 24 | return []; 25 | } 26 | 27 | return value.split(/\s+/gi).filter(Boolean); 28 | }; 29 | 30 | /** 31 | * Cleanes and transforms string of format "x y" into object {x, y}. 32 | * Possible combinations: 33 | * x - left, center, right 34 | * y - top, bottom 35 | */ 36 | export const listToDirection = (value: string | string[]): Direction => { 37 | if (typeof value === 'string') { 38 | value = split(value); 39 | } 40 | 41 | let x = null; 42 | let y = null; 43 | 44 | value.forEach(v => { 45 | if (directions.y.has(v)) { 46 | y = v; 47 | } 48 | if (directions.x.has(v)) { 49 | x = v; 50 | } 51 | }); 52 | 53 | return { x, y }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/utils/parser.ts: -------------------------------------------------------------------------------- 1 | const floatRegexp = '[-+]?[0-9]*.?[0-9]+'; 2 | 3 | type ValueType = { 4 | type: string; 5 | value: number | string; 6 | } 7 | 8 | const types = [ 9 | { 10 | name: 'px', 11 | regexp: new RegExp(`^${floatRegexp}px$`), 12 | }, 13 | { 14 | name: '%', 15 | regexp: new RegExp(`^${floatRegexp}%$`), 16 | }, 17 | /** 18 | * Fallback option 19 | * If no suffix specified, assigning "px" 20 | */ 21 | { 22 | name: 'px', 23 | regexp: new RegExp(`^${floatRegexp}$`), 24 | }, 25 | ]; 26 | 27 | const getType = (value: string): ValueType => { 28 | if (value === 'auto') { 29 | return { 30 | type: value, 31 | value: 0, 32 | }; 33 | } 34 | 35 | for (let i = 0; i < types.length; i++) { 36 | const type = types[i]; 37 | if (type.regexp.test(value)) { 38 | return { 39 | type: type.name, 40 | value: parseFloat(value), 41 | }; 42 | } 43 | } 44 | 45 | return { 46 | type: '', 47 | value, 48 | }; 49 | }; 50 | 51 | export const parse = (value: number | string) => { 52 | switch (typeof value) { 53 | case 'number': 54 | return { type: 'px', value }; 55 | case 'string': 56 | return getType(value); 57 | default: 58 | return { type: '', value }; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /vetur/attributes.json: -------------------------------------------------------------------------------- 1 | { 2 | "group": { 3 | "type": "string", 4 | "description": "Name of the notification holder, if specified." 5 | }, 6 | "width": { 7 | "type": ["string", "number"], 8 | "description": "Width of notification holder, can be %, px string or number." 9 | }, 10 | "classes": { 11 | "type": "string", 12 | "description": "List of classes that will be applied to notification element." 13 | }, 14 | "reverse": { 15 | "type": "boolean", 16 | "description": "Show notifications in reverse order" 17 | }, 18 | "position": { 19 | "options": ["top left", "top right", "top center", "bottom left", "bottom right", "bottom center"], 20 | "description": "Part of the screen where notifications will pop out." 21 | }, 22 | "animation-type": { 23 | "options": ["css", "velocity"], 24 | "description": "Type of animation, currently supported types are css and velocity." 25 | }, 26 | "animation": { 27 | "description": "Animation configuration for Velocity animation." 28 | }, 29 | "animation-name": { 30 | "type": "string", 31 | "description": "Animation name required for css animation." 32 | }, 33 | "speed": { 34 | "type": "number", 35 | "description": "Time (in ms) to show / hide notifications." 36 | }, 37 | "duration": { 38 | "type": "number", 39 | "description": "Time (in ms) to keep the notification on screen (if negative - notification will stay forever or until clicked)." 40 | }, 41 | "ignore-duplicates": { 42 | "type": "boolean", 43 | "description": "Ignore repeated instances of the same notification." 44 | }, 45 | "close-on-click": { 46 | "type": "boolean", 47 | "description": "Close notification when clicked." 48 | }, 49 | "pause-on-hover": { 50 | "type": "boolean" 51 | }, 52 | "delay": { 53 | "type": "number" 54 | } 55 | } -------------------------------------------------------------------------------- /docs/guide/typescript.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'TypeScript Support' 3 | --- 4 | # TypeScript Support 5 | 6 | This library is written with TypeScript. Since the notification component is registered globally, you need to register its types. 7 | 8 | You can do this manually: 9 | ```ts 10 | import type { FunctionalComponent } from 'vue'; 11 | import type { Notifications } from '@kyvg/vue3-notification'; 12 | declare module 'vue' { 13 | export interface GlobalComponents { 14 | Notifications: FunctionalComponent; 15 | } 16 | } 17 | ``` 18 | Or, you can use built-in `unplugin-vue-components` resolver. This resolver allows you to seamlessly integrate this library with Vue projects using [`unplugin-vue-components`](https://github.com/unplugin/unplugin-vue-components). It automates the import of components, making your development process more efficient. 19 | 20 | ## Installation 21 | To get started, install the necessary packages using npm or yarn: 22 | ```bash 23 | npm install --save @kyvg/vue3-notification unplugin-vue-components 24 | # or 25 | yarn add @kyvg/vue3-notification unplugin-vue-components 26 | ``` 27 | ## Configuration 28 | To configure the resolver, update your Vue project's plugin settings. For example, in a Vite project, modify vite.config.js: 29 | ```js 30 | import Components from 'unplugin-vue-components/vite'; 31 | import NotificationsResolver from '@kyvg/vue3-notification/auto-import-resolver'; 32 | 33 | export default { 34 | plugins: [ 35 | Components({ 36 | resolvers: [NotificationsResolver()], 37 | }), 38 | ], 39 | } 40 | ``` 41 | Specify the custom component's name if you have configured it: 42 | ```js 43 | // main.js 44 | app.use(Notifications, { componentName: "Alert" }); 45 | ``` 46 | ::: warning 47 | Note that component name should be in PascalCase 48 | ::: 49 | ```js 50 | import Components from 'unplugin-vue-components/vite'; 51 | import NotificationsResolver from '@kyvg/vue3-notification/auto-import-resolver'; 52 | 53 | export default { 54 | plugins: [ 55 | Components({ 56 | resolvers: [NotificationsResolver("Alert")], 57 | }), 58 | ], 59 | } 60 | ``` 61 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a VitePress site to GitHub Pages 2 | # 3 | name: Deploy VitePress site to Pages 4 | 5 | on: 6 | # Runs on pushes targeting the `main` branch. Change this to `master` if you're 7 | # using the `master` branch as the default branch. 8 | push: 9 | branches: [master] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | 20 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 21 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 22 | concurrency: 23 | group: pages 24 | cancel-in-progress: false 25 | 26 | jobs: 27 | # Build job 28 | build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 # Not needed if lastUpdated is not enabled 35 | # - uses: pnpm/action-setup@v2 # Uncomment this if you're using pnpm 36 | # - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun 37 | - name: Setup Node 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 20 41 | cache: npm # or pnpm / yarn 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v4 44 | - name: Install dependencies 45 | run: npm ci # or pnpm install / yarn install / bun install 46 | - name: Build with VitePress 47 | run: | 48 | npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build 49 | touch docs/.vitepress/dist/.nojekyll 50 | - name: Upload artifact 51 | uses: actions/upload-pages-artifact@v3 52 | with: 53 | path: docs/.vitepress/dist 54 | 55 | # Deployment job 56 | deploy: 57 | environment: 58 | name: github-pages 59 | url: ${{ steps.deployment.outputs.page_url }} 60 | needs: build 61 | runs-on: ubuntu-latest 62 | name: Deploy 63 | steps: 64 | - name: Deploy to GitHub Pages 65 | id: deployment 66 | uses: actions/deploy-pages@v4 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kyvg/vue3-notification", 3 | "description": "Vue.js Notification Library", 4 | "version": "3.4.2", 5 | "author": "kyvg", 6 | "private": false, 7 | "publishConfig": { 8 | "registry": "https://registry.npmjs.org" 9 | }, 10 | "type": "module", 11 | "vetur": { 12 | "tags": "vetur/tags.json", 13 | "attributes": "vetur/attributes.json" 14 | }, 15 | "scripts": { 16 | "release": "npm run build && npm run build:resolver && tail -n +3 src/global-extensions.d.ts >> dist/index.d.ts", 17 | "build": "vite build", 18 | "build:resolver": "vite build -c vite.resolver.config.js", 19 | "unit": "vitest", 20 | "unit:watch": "vitest --watch", 21 | "test": "npm run unit", 22 | "lint": "eslint ./src/index.ts", 23 | "docs:dev": "vitepress dev docs", 24 | "docs:build": "vitepress build docs", 25 | "docs:preview": "vitepress preview docs", 26 | "preversion": "npm run test && npm run release && git add -A dist" 27 | }, 28 | "typings": "./dist/index.d.ts", 29 | "main": "./dist/index.umd.js", 30 | "module": "./dist/index.es.js", 31 | "style": "./dist/index.css", 32 | "exports": { 33 | ".": { 34 | "types": "./dist/index.d.ts", 35 | "import": "./dist/index.es.js", 36 | "require": "./dist/index.umd.js" 37 | }, 38 | "./auto-import-resolver": { 39 | "types": "./dist/auto-import-resolver/index.d.ts", 40 | "import": "./dist/auto-import-resolver/index.es.js", 41 | "require": "./dist/auto-import-resolver/index.umd.js" 42 | } 43 | }, 44 | "files": [ 45 | "dist" 46 | ], 47 | "repository": { 48 | "type": "git", 49 | "url": "https://github.com/kyvg/vue3-notification.git" 50 | }, 51 | "bugs": { 52 | "url": "https://github.com/kyvg/vue3-notification/issues" 53 | }, 54 | "license": "MIT", 55 | "peerDependencies": { 56 | "vue": "^3.0.0" 57 | }, 58 | "keywords": [ 59 | "web", 60 | "front-end", 61 | "vue", 62 | "vuejs", 63 | "vue3", 64 | "notification", 65 | "vue-notification", 66 | "vue-notifications" 67 | ], 68 | "devDependencies": { 69 | "@typescript-eslint/eslint-plugin": "^8.8.1", 70 | "@typescript-eslint/parser": "^8.8.1", 71 | "@vitejs/plugin-vue-jsx": "^4.0.1", 72 | "@vue/compiler-sfc": "^3.5.11", 73 | "@vue/test-utils": "^2.4.6", 74 | "eslint": "^8.57.0", 75 | "eslint-plugin-vue": "^9.27.0", 76 | "jsdom": "^25.0.1", 77 | "mitt": "^3.0.1", 78 | "sass": "^1.79.4", 79 | "typescript": "^5.6.2", 80 | "unplugin-vue-components": "^0.27.4", 81 | "vite": "^5.4.8", 82 | "vite-plugin-css-injected-by-js": "^3.5.1", 83 | "vite-plugin-dts": "^4.2.3", 84 | "vitepress": "^1.4.0", 85 | "vitest": "^2.1.2", 86 | "vue": "^3.5.11" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable quote-props */ 2 | module.exports = { 3 | root: true, 4 | env: { 5 | node: true, 6 | }, 7 | plugins: [ 8 | 'vue', 9 | '@typescript-eslint', 10 | ], 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:@typescript-eslint/eslint-recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'plugin:vue/vue3-recommended', 16 | ], 17 | rules: { 18 | '@typescript-eslint/ban-types': 'off', 19 | '@typescript-eslint/ban-ts-comment': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], 22 | '@typescript-eslint/prefer-interface': 'off', 23 | 24 | 'brace-style': ['error', '1tbs'], 25 | 'comma-dangle': ['error', 'always-multiline'], 26 | curly: 'error', 27 | 'eol-last': 'error', 28 | 'eqeqeq': 'error', 29 | 'jsx-quotes': ['error', 'prefer-single'], 30 | 'no-case-declarations': 'error', 31 | 'no-console': ['warn', { 'allow': ['warn', 'error'] }], 32 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 33 | 'no-multi-spaces': 'error', 34 | 'no-multiple-empty-lines': 'error', 35 | 'no-shadow': ['error', { 'allow': ['required', 'numeric', 'between', 'minLength'] }], 36 | 'no-unused-vars': 'off', 37 | 'no-var': 'error', 38 | 'object-curly-spacing': ['error', 'always'], 39 | 'object-shorthand': 'error', 40 | 'prefer-const': 'error', 41 | 'quote-props': ['error', 'as-needed'], 42 | 'quotes': ['error', 'single', 'avoid-escape'], 43 | 'semi': 'error', 44 | // 'sort-keys': 'error', 45 | 'space-before-function-paren': ['error', { 46 | anonymous: 'never', 47 | named: 'never', 48 | asyncArrow: 'always', 49 | }], 50 | 51 | 'vue/component-definition-name-casing': ['error', 'kebab-case'], 52 | 'vue/component-name-in-template-casing': ['error', 'kebab-case', { 53 | 'registeredComponentsOnly': true, 54 | 'ignores': [], 55 | }], 56 | 'vue/eqeqeq': 'error', 57 | 'vue/html-quotes': ['error', 'double'], 58 | 'vue/match-component-file-name': ['error', { 59 | 'extensions': ['vue'], 60 | 'shouldMatchCase': false, 61 | }], 62 | 'vue/max-attributes-per-line': ['error', { 63 | 'singleline': { 64 | 'max': 2, 65 | }, 66 | 'multiline': { 67 | 'max': 1, 68 | }, 69 | }], 70 | 'vue/no-deprecated-scope-attribute': 'error', 71 | 'vue/no-deprecated-slot-scope-attribute': 'error', 72 | 'vue/no-irregular-whitespace': 'error', 73 | 'vue/no-static-inline-styles': 'error', 74 | 'vue/require-name-property': 'error', 75 | 'vue/singleline-html-element-content-newline': 'off', 76 | 'vue/v-slot-style': 'error', 77 | }, 78 | parser: 'vue-eslint-parser', 79 | parserOptions: { 80 | parser: '@typescript-eslint/parser', 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /docs/guide/usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Usage' 3 | --- 4 | 5 | # Usage 6 | 7 | Trigger notifications from your `.vue` files: 8 | 9 | ```javascript 10 | // simple 11 | this.$notify("Hello user!"); 12 | 13 | // using options 14 | this.$notify({ 15 | title: "Important message", 16 | text: "Hello user!", 17 | }); 18 | ``` 19 | 20 | Or trigger notifications from other files, for example, your router: 21 | 22 | ::: code-group 23 | 24 | ```javascript [Composition API] 25 | import { useNotification } from "@kyvg/vue3-notification"; 26 | 27 | const { notify } = useNotification() 28 | 29 | notify({ 30 | title: "Authorization", 31 | text: "You have been logged in!", 32 | }); 33 | ``` 34 | 35 | ```javascript [Options API] 36 | import { notify } from "@kyvg/vue3-notification"; 37 | 38 | notify({ 39 | title: "Authorization", 40 | text: "You have been logged in!", 41 | }); 42 | ``` 43 | 44 | ::: 45 | 46 | ## Position 47 | 48 | Position the component on the screen using the `position` prop: 49 | 50 | ```vue 51 | 52 | ``` 53 | 54 | It requires a `string` with **two keywords** for vertical and horizontal postion. 55 | 56 | Format: `" "`. 57 | 58 | - Horizontal options: `left`, `center`, `right` 59 | - Vertical options: `top`, `bottom` 60 | 61 | Default is `"top right"`. 62 | 63 | ## Width 64 | 65 | Width can be set using a `number` or `string` with optional `%` or `px` extensions: 66 | 67 | ::: info 68 | Value without extensions will be parsed as `px` 69 | ::: 70 | 71 | ```vue 72 | 73 | 74 | 75 | 76 | ``` 77 | 78 | ## Type 79 | 80 | Set the `type` of a notification (**warn**, **error**, **success**, etc) by adding a `type` property to the call: 81 | 82 | ```js 83 | this.$notify({ type: "success", text: "The operation completed" }); 84 | ``` 85 | 86 | This will add the `type` (i.e. "success") as a CSS class name to the `.vue-notification` element. 87 | 88 | See the [Styling](#styling) section for how to hook onto the class and style the popup. 89 | 90 | ## Groups 91 | 92 | For different classes of notifications, i.e... 93 | 94 | - authentication errors (top center) 95 | - app notifications (bottom-right) 96 | 97 | ...specify the `group` attribute: 98 | 99 | ```vue 100 | 101 | 102 | ``` 103 | 104 | Trigger a notification for a specific group by specifying it in the API call: 105 | 106 | ```javascript 107 | this.$notify({ group: "auth", text: "Wrong password, please try again" }); 108 | ``` 109 | 110 | ## Programatically Closing 111 | 112 | ::: code-group 113 | 114 | ```javascript [Composition API] 115 | import { useNotification } from "@kyvg/vue3-notification" 116 | 117 | const { notify } = useNotification() 118 | 119 | const id = Date.now() // This can be any unique number 120 | 121 | notify({ 122 | id, 123 | text: 'This message will be removed immediately' 124 | }) 125 | 126 | notify.close(id) 127 | ``` 128 | 129 | ```javascript [Options API] 130 | 131 | const id = Date.now() // This can be any unique number 132 | 133 | this.$notify({ 134 | id, 135 | text: 'This message will be removed immediately' 136 | }); 137 | 138 | this.$notify.close(id); 139 | ``` 140 | ::: 141 | -------------------------------------------------------------------------------- /test/unit/specs/util.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { Id, split, listToDirection } from '@/utils'; 3 | 4 | describe('util functions', () => { 5 | describe('Id', () => { 6 | it('sequentially generates id starting with 0', () => { 7 | const results = [...Array(5)].map(() => Id()); 8 | 9 | expect(results).toStrictEqual([0,1,2,3,4]); 10 | }); 11 | }); 12 | 13 | describe('split', () => { 14 | it('splits space-separated string into array', () => { 15 | const value = 'taco pizza sushi'; 16 | const result = split(value); 17 | 18 | expect(result).toStrictEqual(['taco', 'pizza', 'sushi']); 19 | }); 20 | 21 | it('splits tab-separated string into array', () => { 22 | const value = 'taco\tpizza\tsushi'; 23 | const result = split(value); 24 | 25 | expect(result).toStrictEqual(['taco', 'pizza', 'sushi']); 26 | }); 27 | 28 | it('removes empty string items', () => { 29 | const value = 'taco \n \n \n'; 30 | const result = split(value); 31 | 32 | expect(result).toStrictEqual(['taco']); 33 | }); 34 | 35 | it('returns empty array when not a string value', () => { 36 | const values = [ 37 | 123, 38 | undefined, 39 | null, 40 | {}, 41 | [], 42 | ]; 43 | 44 | values.forEach((value) => { 45 | const result = split(value); 46 | 47 | expect(result).toStrictEqual([]); 48 | }); 49 | }); 50 | }); 51 | 52 | describe('listToDirection', () => { 53 | describe('when value is array', () => { 54 | it('returns object with x and y keys representing position', () => { 55 | const scenarios = [ 56 | { value: ['top', 'left'], result: { x: 'left', y: 'top' } }, 57 | { value: ['top', 'center'], result: { x: 'center', y: 'top' } }, 58 | { value: ['top', 'right'], result: { x: 'right', y: 'top' } }, 59 | { value: ['bottom', 'left'], result: { x: 'left', y: 'bottom' } }, 60 | { value: ['bottom', 'center'], result: { x: 'center', y: 'bottom' } }, 61 | { value: ['bottom', 'right'], result: { x: 'right', y: 'bottom' } }, 62 | ]; 63 | 64 | scenarios.forEach((scenario) => { 65 | const result = listToDirection(scenario.value); 66 | 67 | expect(result).toStrictEqual(scenario.result); 68 | }); 69 | }); 70 | }); 71 | 72 | describe('when value is string', () => { 73 | it('returns object with x and y keys representing position', () => { 74 | const scenarios = [ 75 | { value: 'top left', result: { x: 'left', y: 'top' } }, 76 | { value: 'top center', result: { x: 'center', y: 'top' } }, 77 | { value: 'top right', result: { x: 'right', y: 'top' } }, 78 | { value: 'bottom left', result: { x: 'left', y: 'bottom' } }, 79 | { value: 'bottom center', result: { x: 'center', y: 'bottom' } }, 80 | { value: 'bottom right', result: { x: 'right', y: 'bottom' } }, 81 | ]; 82 | 83 | scenarios.forEach((scenario) => { 84 | const result = listToDirection(scenario.value); 85 | 86 | expect(result).toStrictEqual(scenario.result); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('when position is invalid', () => { 92 | // TODO: may need to handle invalid cases better 93 | it('returns null for the respective x or y key', () => { 94 | const scenarios = [ 95 | { value: 'top pizza', result: { x: null, y: 'top' } }, 96 | { value: 'pizza right', result: { x: 'right', y: null } }, 97 | { value: 'taco pizza', result: { x: null, y: null } }, 98 | ]; 99 | 100 | scenarios.forEach((scenario) => { 101 | const result = listToDirection(scenario.value); 102 | 103 | expect(result).toStrictEqual(scenario.result); 104 | }); 105 | }); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /docs/guide/customization.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Customization' 3 | --- 4 | 5 | # Customization 6 | 7 | ## Styling 8 | 9 | Vue Notifications comes with default styling, but it's easy to replace with your own. 10 | 11 | Specify one or more class hooks via the `classes` prop on the global component: 12 | 13 | ```vue 14 | 15 | ``` 16 | 17 | This will add the supplied class/classes to individual notification elements: 18 | 19 | ```html 20 |
21 |
22 |
Info
23 |
You have been logged in
24 |
25 |
26 | ``` 27 | 28 | Then include custom css rules to style the notifications: 29 | 30 | ```scss 31 | // style of the notification itself 32 | .my-notification { 33 | /*...*/ 34 | 35 | // style for title line 36 | .notification-title { 37 | /*...*/ 38 | } 39 | 40 | // style for content 41 | .notification-content { 42 | /*...*/ 43 | } 44 | 45 | // additional styling hook when using`type` parameter, i.e. this.$notify({ type: 'success', message: 'Yay!' }) 46 | &.success { 47 | /*...*/ 48 | } 49 | &.info { 50 | /*...*/ 51 | } 52 | &.error { 53 | /*...*/ 54 | } 55 | } 56 | ``` 57 | 58 | Note that the default rules are: 59 | 60 | ```scss 61 | .vue-notification { 62 | // styling 63 | margin: 0 5px 5px; 64 | padding: 10px; 65 | font-size: 12px; 66 | color: #ffffff; 67 | 68 | // default (blue) 69 | background: #44a4fc; 70 | border-left: 5px solid #187fe7; 71 | 72 | // types (green, amber, red) 73 | &.success { 74 | background: #68cd86; 75 | border-left-color: #42a85f; 76 | } 77 | 78 | &.warn { 79 | background: #ffb648; 80 | border-left-color: #f48a06; 81 | } 82 | 83 | &.error { 84 | background: #e54d42; 85 | border-left-color: #b82e24; 86 | } 87 | } 88 | ``` 89 | 90 | ## Content 91 | 92 | To completely replace notification content, use Vue's slots system: 93 | 94 | ```vue 95 | 96 | 107 | 108 | ``` 109 | 110 | The `props` object has the following members: 111 | 112 | | Name | Type | Description | 113 | | ----- | -------- | ------------------------------------ | 114 | | item | Object | Notification object | 115 | | close | Function | A function to close the notification | 116 | 117 | 118 | 119 | ## Animation 120 | 121 | Vue Notification can use the [Velocity](https://github.com/julianshapiro/velocity) library to power the animations using JavaScript. 122 | 123 | To use, manually install `velocity-animate` & pass the library to the `vue-notification` plugin (the reason for doing that is to reduce the size of this plugin). 124 | 125 | In your `main.js`: 126 | 127 | ```javascript 128 | import { createApp } from 'vue' 129 | import Notifications from '@kyvg/vue3-notification' 130 | import velocity from 'velocity-animate' 131 | 132 | const app = createApp({...}) 133 | app.use(Notifications, { velocity }) 134 | ``` 135 | 136 | In the template, set the `animation-type` prop: 137 | 138 | ```vue 139 | 140 | ``` 141 | 142 | The default configuration is: 143 | 144 | ```js 145 | { 146 | enter: { opacity: [1, 0] }, 147 | leave: { opacity: [0, 1] } 148 | } 149 | ``` 150 | 151 | To assign a custom animation, use the `animation` prop: 152 | 153 | ```vue 154 | 155 | ``` 156 | 157 | Note that `enter` and `leave` can be an `object` or a `function` that returns an `object`: 158 | 159 | ```javascript 160 | computed: { 161 | animation () { 162 | return { 163 | /** 164 | * Animation function 165 | * 166 | * Runs before animating, so you can take the initial height, width, color, etc 167 | * @param {HTMLElement} element The notification element 168 | */ 169 | enter (element) { 170 | let height = element.clientHeight 171 | return { 172 | // animates from 0px to "height" 173 | height: [height, 0], 174 | 175 | // animates from 0 to random opacity (in range between 0.5 and 1) 176 | opacity: [Math.random() * 0.5 + 0.5, 0] 177 | } 178 | }, 179 | leave: { 180 | height: 0, 181 | opacity: 0 182 | } 183 | } 184 | } 185 | } 186 | ``` 187 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API Reference 3 | --- 4 | 5 | # API Reference 6 | 7 | The majority of settings for the Notifications component are configured using props: 8 | 9 | ```vue 10 | 15 | ``` 16 | 17 | ## Props 18 | 19 | ::: tip 20 | Note that all props are optional. 21 | ::: 22 | 23 | | Name | Type | Default | Description | 24 | | ---------------- | ------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | 25 | | position | String/Array | 'top right' | Part of the screen where notifications will pop out | 26 | | width | Number/String | 300 | Width of notification holder, can be `%`, `px` string or number.
Valid values: '100%', '200px', 200 | 27 | | classes | String/Array | 'vue-notification' | List of classes that will be applied to notification element | 28 | | group | String | null | Name of the notification holder, if specified | 29 | | duration | Number | 3000 | Time (in ms) to keep the notification on screen (if **negative** - notification will stay **forever** or until clicked) | 30 | | speed | Number | 300 | Time (in ms) to show / hide notifications | 31 | | animation-type | String | 'css' | Type of animation, currently supported types are `css` and `velocity` | 32 | | animation-name | String | null | Animation name required for `css` animation | 33 | | animation | Object | Custom | Animation configuration for Velocity animation | 34 | | max | Number | Infinity | Maximum number of notifications that can be shown in notification holder | 35 | | reverse | Boolean | false | Show notifications in reverse order | 36 | | ignoreDuplicates | Boolean | false | Ignore repeated instances of the same notification | 37 | | closeOnClick | Boolean | true | Close notification when clicked | 38 | | pauseOnHover | Boolean | false | Keep the notification open while mouse hovers on notification | 39 | | dangerouslySetInnerHtml | Boolean | false | Use [v-html](https://vuejs.org/api/built-in-directives.html#v-html) to set `title` and `text` | 40 | 41 | ## Events 42 | | Name | Type | Description | 43 | | ---------------- | -------------------------------- | -------------------------------------------- | 44 | | click | (item: NotificationItem) => void | The callback function that is triggered when notification was clicked 45 | | destroy | (item: NotificationItem) => void | The callback function that is triggered when notification was destroyes 46 | | start | (item: NotificationItem) => void | The callback function that is triggered when notification was appeared 47 | 48 | ## Plugin options 49 | 50 | Configure the plugin itself using an additional options object: 51 | 52 | ```js 53 | app.use(Notifications, { name: "alert" }); 54 | ``` 55 | 56 | ::: warning 57 | Setting `componentName` can cause issues when using SSR. 58 | ::: 59 | 60 | All options are optional: 61 | 62 | | Name | Type | Default | Description | 63 | | ------------- | ------ |----------------|-------------------------------------------------------------------------------| 64 | | name | String | notify | Defines the instance name. It's prefixed with the dollar sign. E.g. `this.$notify` | 65 | | componentName | String | Notifications | The component's name | -------------------------------------------------------------------------------- /docs/components/Demo.vue: -------------------------------------------------------------------------------- 1 | 113 | 114 | 149 | 150 | 280 | -------------------------------------------------------------------------------- /dist/index.umd.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";var o;try{if(typeof document<"u"){var e=document.createElement("style");e.nonce=(o=document.head.querySelector("meta[property=csp-nonce]"))==null?void 0:o.content,e.appendChild(document.createTextNode(".vue-notification-group{display:block;position:fixed;z-index:5000}.vue-notification-wrapper{display:block;overflow:hidden;width:100%;margin:0;padding:0}.notification-title{font-weight:600}.vue-notification-template{display:block;box-sizing:border-box;background:#fff;text-align:left}.vue-notification{display:block;box-sizing:border-box;text-align:left;font-size:12px;padding:10px;margin:0 5px 5px;color:#fff;background:#44a4fc;border-left:5px solid #187FE7}.vue-notification.warn{background:#ffb648;border-left-color:#f48a06}.vue-notification.error{background:#e54d42;border-left-color:#b82e24}.vue-notification.success{background:#68cd86;border-left-color:#42a85f}.vn-fade-enter-active,.vn-fade-leave-active,.vn-fade-move{transition:all .5s}.vn-fade-enter-from,.vn-fade-leave-to{opacity:0}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})(); 2 | (function(l,o){typeof exports=="object"&&typeof module<"u"?o(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],o):(l=typeof globalThis<"u"?globalThis:l||self,o(l.notifications={},l.Vue))})(this,function(l,o){"use strict";const S=new Map;function L(t){return{all:t=t||new Map,on:function(i,a){var s=t.get(i);s?s.push(a):t.set(i,[a])},off:function(i,a){var s=t.get(i);s&&(a?s.splice(s.indexOf(a)>>>0,1):t.set(i,[]))},emit:function(i,a){var s=t.get(i);s&&s.slice().map(function(r){r(a)}),(s=t.get("*"))&&s.slice().map(function(r){r(i,a)})}}}const p=L(),b="[-+]?[0-9]*.?[0-9]+",D=[{name:"px",regexp:new RegExp(`^${b}px$`)},{name:"%",regexp:new RegExp(`^${b}%$`)},{name:"px",regexp:new RegExp(`^${b}$`)}],k=t=>{if(t==="auto")return{type:t,value:0};for(let i=0;i{switch(typeof t){case"number":return{type:"px",value:t};case"string":return k(t);default:return{type:"",value:t}}},O={x:new Set(["left","center","right"]),y:new Set(["top","bottom"])},v=(t=>()=>t++)(0),P=t=>typeof t!="string"?[]:t.split(/\s+/gi).filter(Boolean),_=t=>{typeof t=="string"&&(t=P(t));let i=null,a=null;return t.forEach(s=>{O.y.has(s)&&(a=s),O.x.has(s)&&(i=s)}),{x:i,y:a}},E={position:["top","right"],cssAnimation:"vn-fade",velocityAnimation:{enter:t=>({height:[t.clientHeight,0],opacity:[1,0]}),leave:{height:0,opacity:[0,1]}}},Y=(t,i)=>{let a,s,r=i;const g=()=>{s=Date.now(),a=setTimeout(t,r)},h=()=>{clearTimeout(a),r-=Date.now()-s};return g(),{start:g,stop:h}};function F(t){return typeof t=="function"||Object.prototype.toString.call(t)==="[object Object]"&&!o.isVNode(t)}const x={IDLE:0,DESTROYED:2},w=o.defineComponent({name:"notifications",props:{group:{type:String,default:""},width:{type:[Number,String],default:300},reverse:{type:Boolean,default:!1},position:{type:[String,Array],default:()=>E.position},classes:{type:[String,Array],default:"vue-notification"},animationType:{type:String,default:"css",validator(t){return t==="css"||t==="velocity"}},animation:{type:Object,default(){return E.velocityAnimation}},animationName:{type:String,default:E.cssAnimation},speed:{type:Number,default:300},duration:{type:Number,default:3e3},delay:{type:Number,default:0},max:{type:Number,default:1/0},ignoreDuplicates:{type:Boolean,default:!1},closeOnClick:{type:Boolean,default:!0},pauseOnHover:{type:Boolean,default:!1},dangerouslySetInnerHtml:{type:Boolean,default:!1}},emits:{click:t=>!0,destroy:t=>!0,start:t=>!0},slots:Object,setup:(t,{emit:i,slots:a,expose:s})=>{const r=o.ref([]),g=S.get("velocity"),h=o.computed(()=>t.animationType==="velocity"),u=o.computed(()=>r.value.filter(e=>e.state!==x.DESTROYED)),V=o.computed(()=>C(t.width)),A=o.computed(()=>{const{x:e,y:n}=_(t.position),c=V.value.value,f=V.value.type,m={width:c+f};return n&&(m[n]="0px"),e&&(e==="center"?m.left=`calc(50% - ${+c/2}${f})`:m[e]="0px"),m}),z=o.computed(()=>h.value?{onEnter:nt,onLeave:ot,onAfterLeave:j}:{}),J=e=>{i("click",e),t.closeOnClick&&y(e)},K=e=>{var n;t.pauseOnHover&&((n=e.timer)==null||n.stop())},Q=e=>{var n;t.pauseOnHover&&((n=e.timer)==null||n.start())},M=(e={})=>{if(e.group||(e.group=""),e.data||(e.data={}),t.group!==e.group)return;if(e.clean||e.clear){et();return}const n=typeof e.duration=="number"?e.duration:t.duration,c=typeof e.speed=="number"?e.speed:t.speed,f=typeof e.ignoreDuplicates=="boolean"?e.ignoreDuplicates:t.ignoreDuplicates,{title:m,text:it,type:at,data:st,id:ct}=e,d={id:ct||v(),title:m,text:it,type:at,state:x.IDLE,speed:c,length:n+2*c,data:st,duplicates:0};n>=0&&(d.timer=Y(()=>y(d),d.length));const H="bottom"in A.value,rt=t.reverse?!H:H;let T=-1;const R=u.value.find(B=>B.title===e.title&&B.text===e.text);if(f&&R){R.duplicates++;return}rt?(r.value.push(d),i("start",d),u.value.length>t.max&&(T=0)):(r.value.unshift(d),i("start",d),u.value.length>t.max&&(T=u.value.length-1)),T!==-1&&y(u.value[T])},I=e=>{tt(e)},X=e=>["vue-notification-template",t.classes,e.type||""],Z=e=>h.value?void 0:{transition:`all ${e.speed}ms`},y=e=>{var n;(n=e.timer)==null||n.stop(),e.state=x.DESTROYED,j(),i("destroy",e)},tt=e=>{const n=r.value.find(c=>c.id===e);n&&y(n)},et=()=>{u.value.forEach(y)},$=(e,n)=>{var f;const c=(f=t.animation)==null?void 0:f[e];return typeof c=="function"?c(n):c},nt=(e,n)=>{const c=$("enter",e);g(e,c,{duration:t.speed,complete:n})},ot=(e,n)=>{const c=$("leave",e);g(e,c,{duration:t.speed,complete:n})};function j(){r.value=r.value.filter(e=>e.state!==x.DESTROYED)}return o.onMounted(()=>{p.on("add",M),p.on("close",I)}),o.onUnmounted(()=>{p.off("add",M),p.off("close",I)}),()=>{let e;return o.createVNode("div",{class:"vue-notification-group",style:A.value},[o.createVNode(o.TransitionGroup,o.mergeProps(z.value,{tag:"div",css:!h.value,name:t.animationName}),F(e=u.value.map(n=>o.createVNode("div",{key:n.id,class:"vue-notification-wrapper",style:Z(n),"data-id":n.id,onMouseenter:()=>K(n),onMouseleave:()=>Q(n)},[a.body?a.body({item:n,class:[t.classes,n.type],close:()=>y(n)}):o.createVNode("div",{class:X(n),onClick:()=>J(n)},[t.dangerouslySetInnerHtml?o.createVNode(o.Fragment,null,[n.title?o.createVNode("div",{class:"notification-title",innerHTML:n.title},null):null,o.createVNode("div",{class:"notification-content",innerHTML:n.text},null)]):o.createVNode(o.Fragment,null,[n.title?o.createVNode("div",{class:"notification-title"},[n.title]):null,o.createVNode("div",{class:"notification-content"},[n.text])])])])))?e:{default:()=>[e]})])}}}),N=t=>{typeof t=="string"&&(t={title:"",text:t}),typeof t=="object"&&p.emit("add",t)};N.close=t=>{p.emit("close",t)};const G=()=>({notify:N}),W="Notifications";function q(t,i={}){Object.entries(i).forEach(s=>S.set(...s));const a=i.name||"notify";t.config.globalProperties["$"+a]=N,t.component(i.componentName||W,w)}const U={install:q};l.Notifications=w,l.default=U,l.notify=N,l.useNotification=G,Object.defineProperties(l,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": [ 10 | "ESNext", 11 | "DOM" 12 | ], /* Specify library files to be included in the compilation. */ 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | "jsx": "preserve", 16 | "jsxImportSource": "vue", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ 17 | "jsxFactory": "h", 18 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 19 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 20 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 21 | // "outFile": "./", /* Concatenate and emit output to single file. */ 22 | // "outDir": "./", /* Redirect output structure to the directory. */ 23 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 24 | // "composite": true, /* Enable project compilation */ 25 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 26 | // "removeComments": true, /* Do not emit comments to output. */ 27 | // "noEmit": true, /* Do not emit outputs. */ 28 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 29 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 30 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 31 | 32 | /* Strict Type-Checking Options */ 33 | "strict": true, /* Enable all strict type-checking options. */ 34 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 35 | // "strictNullChecks": true, /* Enable strict null checks. */ 36 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 37 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 38 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 39 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 40 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 41 | 42 | /* Additional Checks */ 43 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 44 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 45 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 46 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 47 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 48 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ 49 | 50 | /* Module Resolution Options */ 51 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 52 | "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 53 | "paths": { 54 | "@/*": ["./src/*"] 55 | }, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 56 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 57 | // "typeRoots": [], /* List of folders to include type definitions from. */ 58 | // "types": [], /* Type declaration files to be included in compilation. */ 59 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 60 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 61 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 62 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 63 | 64 | /* Source Map Options */ 65 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 66 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 67 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 68 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 69 | 70 | /* Experimental Options */ 71 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 72 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 73 | 74 | /* Advanced Options */ 75 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 76 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 77 | "resolveJsonModule": true 78 | }, 79 | } 80 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ComponentOptionsMixin } from 'vue'; 2 | import { ComponentProvideOptions } from 'vue'; 3 | import { DefineComponent } from 'vue'; 4 | import { ExtractPropTypes } from 'vue'; 5 | import { HTMLAttributes } from 'vue'; 6 | import { JSX } from 'vue/jsx-runtime'; 7 | import { Plugin as Plugin_2 } from 'vue'; 8 | import { PropType } from 'vue'; 9 | import { PublicProps } from 'vue'; 10 | import { SlotsType } from 'vue'; 11 | 12 | declare const _default: Plugin_2; 13 | export default _default; 14 | 15 | export declare type NotificationItem = Pick & { 16 | length: number; 17 | duplicates: number; 18 | }; 19 | 20 | export declare const Notifications: DefineComponent; 39 | default: () => string[]; 40 | }; 41 | classes: { 42 | type: PropType; 43 | default: string; 44 | }; 45 | animationType: { 46 | type: PropType<"css" | "velocity">; 47 | default: string; 48 | validator(value: unknown): value is "css" | "velocity"; 49 | }; 50 | animation: { 51 | type: PropType>; 52 | default(): { 53 | enter: (el: Element) => { 54 | height: number[]; 55 | opacity: number[]; 56 | }; 57 | leave: { 58 | height: number; 59 | opacity: number[]; 60 | }; 61 | }; 62 | }; 63 | animationName: { 64 | type: StringConstructor; 65 | default: string; 66 | }; 67 | speed: { 68 | type: NumberConstructor; 69 | default: number; 70 | }; 71 | /** Time (in ms) to keep the notification on screen (if **negative** - notification will stay **forever** or until clicked) */ 72 | duration: { 73 | type: NumberConstructor; 74 | default: number; 75 | }; 76 | delay: { 77 | type: NumberConstructor; 78 | default: number; 79 | }; 80 | max: { 81 | type: NumberConstructor; 82 | default: number; 83 | }; 84 | ignoreDuplicates: { 85 | type: BooleanConstructor; 86 | default: boolean; 87 | }; 88 | closeOnClick: { 89 | type: BooleanConstructor; 90 | default: boolean; 91 | }; 92 | pauseOnHover: { 93 | type: BooleanConstructor; 94 | default: boolean; 95 | }; 96 | /** Use [v-html](https://vuejs.org/api/built-in-directives.html#v-html) to set `title` and `text` */ 97 | dangerouslySetInnerHtml: { 98 | type: BooleanConstructor; 99 | default: boolean; 100 | }; 101 | }>, () => JSX.Element, {}, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, { 102 | click: (item: NotificationItem) => true; 103 | destroy: (item: NotificationItem) => true; 104 | start: (item: NotificationItem) => true; 105 | }, string, PublicProps, Readonly; 124 | default: () => string[]; 125 | }; 126 | classes: { 127 | type: PropType; 128 | default: string; 129 | }; 130 | animationType: { 131 | type: PropType<"css" | "velocity">; 132 | default: string; 133 | validator(value: unknown): value is "css" | "velocity"; 134 | }; 135 | animation: { 136 | type: PropType>; 137 | default(): { 138 | enter: (el: Element) => { 139 | height: number[]; 140 | opacity: number[]; 141 | }; 142 | leave: { 143 | height: number; 144 | opacity: number[]; 145 | }; 146 | }; 147 | }; 148 | animationName: { 149 | type: StringConstructor; 150 | default: string; 151 | }; 152 | speed: { 153 | type: NumberConstructor; 154 | default: number; 155 | }; 156 | /** Time (in ms) to keep the notification on screen (if **negative** - notification will stay **forever** or until clicked) */ 157 | duration: { 158 | type: NumberConstructor; 159 | default: number; 160 | }; 161 | delay: { 162 | type: NumberConstructor; 163 | default: number; 164 | }; 165 | max: { 166 | type: NumberConstructor; 167 | default: number; 168 | }; 169 | ignoreDuplicates: { 170 | type: BooleanConstructor; 171 | default: boolean; 172 | }; 173 | closeOnClick: { 174 | type: BooleanConstructor; 175 | default: boolean; 176 | }; 177 | pauseOnHover: { 178 | type: BooleanConstructor; 179 | default: boolean; 180 | }; 181 | /** Use [v-html](https://vuejs.org/api/built-in-directives.html#v-html) to set `title` and `text` */ 182 | dangerouslySetInnerHtml: { 183 | type: BooleanConstructor; 184 | default: boolean; 185 | }; 186 | }>> & Readonly<{ 187 | onClick?: ((item: NotificationItem) => any) | undefined; 188 | onDestroy?: ((item: NotificationItem) => any) | undefined; 189 | onStart?: ((item: NotificationItem) => any) | undefined; 190 | }>, { 191 | speed: number; 192 | group: string; 193 | duration: number; 194 | ignoreDuplicates: boolean; 195 | closeOnClick: boolean; 196 | width: string | number; 197 | reverse: boolean; 198 | position: string | string[]; 199 | classes: string | string[]; 200 | animationType: "css" | "velocity"; 201 | animation: Record<"enter" | "leave", unknown>; 202 | animationName: string; 203 | delay: number; 204 | max: number; 205 | pauseOnHover: boolean; 206 | dangerouslySetInnerHtml: boolean; 207 | }, SlotsType<{ 208 | body?: (props: { 209 | class: HTMLAttributes["class"]; 210 | item: NotificationItem; 211 | close: () => void; 212 | }) => any; 213 | }>, {}, {}, string, ComponentProvideOptions, true, {}, any>; 214 | 215 | export declare interface NotificationsOptions { 216 | id?: number | string; 217 | title?: string; 218 | text?: string; 219 | type?: NotificationType; 220 | group?: string; 221 | duration?: number; 222 | speed?: number; 223 | data?: unknown; 224 | clean?: boolean; 225 | clear?: boolean; 226 | ignoreDuplicates?: boolean; 227 | closeOnClick?: boolean; 228 | } 229 | 230 | export declare interface NotificationsPluginOptions { 231 | name?: string; 232 | componentName?: string; 233 | velocity?: any; 234 | } 235 | 236 | declare type NotificationType = 'warn' | 'success' | 'error' | (string & {}); 237 | 238 | export declare const notify: { 239 | (args: NotificationsOptions | string): void; 240 | close(id: unknown): void; 241 | }; 242 | 243 | export declare const useNotification: () => { 244 | notify: { 245 | (args: NotificationsOptions | string): void; 246 | close(id: unknown): void; 247 | }; 248 | }; 249 | 250 | export { } 251 | declare module 'vue' { 252 | export interface ComponentCustomProperties { 253 | $notify: typeof notify; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /dist/index.es.js: -------------------------------------------------------------------------------- 1 | (function(){"use strict";var o;try{if(typeof document<"u"){var e=document.createElement("style");e.nonce=(o=document.head.querySelector("meta[property=csp-nonce]"))==null?void 0:o.content,e.appendChild(document.createTextNode(".vue-notification-group{display:block;position:fixed;z-index:5000}.vue-notification-wrapper{display:block;overflow:hidden;width:100%;margin:0;padding:0}.notification-title{font-weight:600}.vue-notification-template{display:block;box-sizing:border-box;background:#fff;text-align:left}.vue-notification{display:block;box-sizing:border-box;text-align:left;font-size:12px;padding:10px;margin:0 5px 5px;color:#fff;background:#44a4fc;border-left:5px solid #187FE7}.vue-notification.warn{background:#ffb648;border-left-color:#f48a06}.vue-notification.error{background:#e54d42;border-left-color:#b82e24}.vue-notification.success{background:#68cd86;border-left-color:#42a85f}.vn-fade-enter-active,.vn-fade-leave-active,.vn-fade-move{transition:all .5s}.vn-fade-enter-from,.vn-fade-leave-to{opacity:0}")),document.head.appendChild(e)}}catch(n){console.error("vite-plugin-css-injected-by-js",n)}})(); 2 | import { defineComponent as Q, ref as X, computed as h, onMounted as Z, onUnmounted as tt, createVNode as l, TransitionGroup as et, mergeProps as nt, Fragment as H, isVNode as ot } from "vue"; 3 | const B = /* @__PURE__ */ new Map(); 4 | function it(t) { 5 | return { all: t = t || /* @__PURE__ */ new Map(), on: function(o, i) { 6 | var a = t.get(o); 7 | a ? a.push(i) : t.set(o, [i]); 8 | }, off: function(o, i) { 9 | var a = t.get(o); 10 | a && (i ? a.splice(a.indexOf(i) >>> 0, 1) : t.set(o, [])); 11 | }, emit: function(o, i) { 12 | var a = t.get(o); 13 | a && a.slice().map(function(r) { 14 | r(i); 15 | }), (a = t.get("*")) && a.slice().map(function(r) { 16 | r(o, i); 17 | }); 18 | } }; 19 | } 20 | const y = it(), T = "[-+]?[0-9]*.?[0-9]+", R = [ 21 | { 22 | name: "px", 23 | regexp: new RegExp(`^${T}px$`) 24 | }, 25 | { 26 | name: "%", 27 | regexp: new RegExp(`^${T}%$`) 28 | }, 29 | /** 30 | * Fallback option 31 | * If no suffix specified, assigning "px" 32 | */ 33 | { 34 | name: "px", 35 | regexp: new RegExp(`^${T}$`) 36 | } 37 | ], at = (t) => { 38 | if (t === "auto") 39 | return { 40 | type: t, 41 | value: 0 42 | }; 43 | for (let o = 0; o < R.length; o++) { 44 | const i = R[o]; 45 | if (i.regexp.test(t)) 46 | return { 47 | type: i.name, 48 | value: parseFloat(t) 49 | }; 50 | } 51 | return { 52 | type: "", 53 | value: t 54 | }; 55 | }, st = (t) => { 56 | switch (typeof t) { 57 | case "number": 58 | return { type: "px", value: t }; 59 | case "string": 60 | return at(t); 61 | default: 62 | return { type: "", value: t }; 63 | } 64 | }, j = { 65 | x: /* @__PURE__ */ new Set(["left", "center", "right"]), 66 | y: /* @__PURE__ */ new Set(["top", "bottom"]) 67 | }, rt = /* @__PURE__ */ ((t) => () => t++)(0), lt = (t) => typeof t != "string" ? [] : t.split(/\s+/gi).filter(Boolean), ct = (t) => { 68 | typeof t == "string" && (t = lt(t)); 69 | let o = null, i = null; 70 | return t.forEach((a) => { 71 | j.y.has(a) && (i = a), j.x.has(a) && (o = a); 72 | }), { x: o, y: i }; 73 | }, b = { 74 | position: ["top", "right"], 75 | cssAnimation: "vn-fade", 76 | velocityAnimation: { 77 | enter: (t) => ({ 78 | height: [t.clientHeight, 0], 79 | opacity: [1, 0] 80 | }), 81 | leave: { 82 | height: 0, 83 | opacity: [0, 1] 84 | } 85 | } 86 | }, ut = (t, o) => { 87 | let i, a, r = o; 88 | const m = () => { 89 | a = Date.now(), i = setTimeout(t, r); 90 | }, g = () => { 91 | clearTimeout(i), r -= Date.now() - a; 92 | }; 93 | return m(), { 94 | start: m, 95 | stop: g 96 | }; 97 | }; 98 | function ft(t) { 99 | return typeof t == "function" || Object.prototype.toString.call(t) === "[object Object]" && !ot(t); 100 | } 101 | const v = { 102 | IDLE: 0, 103 | DESTROYED: 2 104 | }, dt = /* @__PURE__ */ Q({ 105 | // eslint-disable-next-line vue/multi-word-component-names 106 | name: "notifications", 107 | props: { 108 | group: { 109 | type: String, 110 | default: "" 111 | }, 112 | /** 113 | * Width of notification holder, can be `%`, `px` string or number. 114 | * @example '100%', '200px', 200 115 | * */ 116 | width: { 117 | type: [Number, String], 118 | default: 300 119 | }, 120 | reverse: { 121 | type: Boolean, 122 | default: !1 123 | }, 124 | position: { 125 | type: [String, Array], 126 | default: () => b.position 127 | }, 128 | classes: { 129 | type: [String, Array], 130 | default: "vue-notification" 131 | }, 132 | animationType: { 133 | type: String, 134 | default: "css", 135 | validator(t) { 136 | return t === "css" || t === "velocity"; 137 | } 138 | }, 139 | animation: { 140 | type: Object, 141 | default() { 142 | return b.velocityAnimation; 143 | } 144 | }, 145 | animationName: { 146 | type: String, 147 | default: b.cssAnimation 148 | }, 149 | speed: { 150 | type: Number, 151 | default: 300 152 | }, 153 | /** Time (in ms) to keep the notification on screen (if **negative** - notification will stay **forever** or until clicked) */ 154 | duration: { 155 | type: Number, 156 | default: 3e3 157 | }, 158 | delay: { 159 | type: Number, 160 | default: 0 161 | }, 162 | max: { 163 | type: Number, 164 | default: 1 / 0 165 | }, 166 | ignoreDuplicates: { 167 | type: Boolean, 168 | default: !1 169 | }, 170 | closeOnClick: { 171 | type: Boolean, 172 | default: !0 173 | }, 174 | pauseOnHover: { 175 | type: Boolean, 176 | default: !1 177 | }, 178 | /** Use [v-html](https://vuejs.org/api/built-in-directives.html#v-html) to set `title` and `text` */ 179 | dangerouslySetInnerHtml: { 180 | type: Boolean, 181 | default: !1 182 | } 183 | }, 184 | emits: { 185 | /* eslint-disable @typescript-eslint/no-unused-vars */ 186 | click: (t) => !0, 187 | destroy: (t) => !0, 188 | start: (t) => !0 189 | /* eslint-enable @typescript-eslint/no-unused-vars */ 190 | }, 191 | slots: Object, 192 | setup: (t, { 193 | emit: o, 194 | slots: i, 195 | expose: a 196 | }) => { 197 | const r = X([]), m = B.get("velocity"), g = h(() => t.animationType === "velocity"), c = h(() => r.value.filter((e) => e.state !== v.DESTROYED)), D = h(() => st(t.width)), S = h(() => { 198 | const { 199 | x: e, 200 | y: n 201 | } = ct(t.position), s = D.value.value, u = D.value.type, p = { 202 | width: s + u 203 | }; 204 | return n && (p[n] = "0px"), e && (e === "center" ? p.left = `calc(50% - ${+s / 2}${u})` : p[e] = "0px"), p; 205 | }), L = h(() => g.value ? { 206 | onEnter: G, 207 | onLeave: W, 208 | onAfterLeave: A 209 | } : {}), k = (e) => { 210 | o("click", e), t.closeOnClick && d(e); 211 | }, C = (e) => { 212 | var n; 213 | t.pauseOnHover && ((n = e.timer) == null || n.stop()); 214 | }, P = (e) => { 215 | var n; 216 | t.pauseOnHover && ((n = e.timer) == null || n.start()); 217 | }, N = (e = {}) => { 218 | if (e.group || (e.group = ""), e.data || (e.data = {}), t.group !== e.group) 219 | return; 220 | if (e.clean || e.clear) { 221 | F(); 222 | return; 223 | } 224 | const n = typeof e.duration == "number" ? e.duration : t.duration, s = typeof e.speed == "number" ? e.speed : t.speed, u = typeof e.ignoreDuplicates == "boolean" ? e.ignoreDuplicates : t.ignoreDuplicates, { 225 | title: p, 226 | text: U, 227 | type: q, 228 | data: z, 229 | id: J 230 | } = e, f = { 231 | id: J || rt(), 232 | title: p, 233 | text: U, 234 | type: q, 235 | state: v.IDLE, 236 | speed: s, 237 | length: n + 2 * s, 238 | data: z, 239 | duplicates: 0 240 | }; 241 | n >= 0 && (f.timer = ut(() => d(f), f.length)); 242 | const I = "bottom" in S.value, K = t.reverse ? !I : I; 243 | let x = -1; 244 | const $ = c.value.find((M) => M.title === e.title && M.text === e.text); 245 | if (u && $) { 246 | $.duplicates++; 247 | return; 248 | } 249 | K ? (r.value.push(f), o("start", f), c.value.length > t.max && (x = 0)) : (r.value.unshift(f), o("start", f), c.value.length > t.max && (x = c.value.length - 1)), x !== -1 && d(c.value[x]); 250 | }, O = (e) => { 251 | _(e); 252 | }, Y = (e) => ["vue-notification-template", t.classes, e.type || ""], V = (e) => g.value ? void 0 : { 253 | transition: `all ${e.speed}ms` 254 | }, d = (e) => { 255 | var n; 256 | (n = e.timer) == null || n.stop(), e.state = v.DESTROYED, A(), o("destroy", e); 257 | }, _ = (e) => { 258 | const n = r.value.find((s) => s.id === e); 259 | n && d(n); 260 | }, F = () => { 261 | c.value.forEach(d); 262 | }, w = (e, n) => { 263 | var u; 264 | const s = (u = t.animation) == null ? void 0 : u[e]; 265 | return typeof s == "function" ? s(n) : s; 266 | }, G = (e, n) => { 267 | const s = w("enter", e); 268 | m(e, s, { 269 | duration: t.speed, 270 | complete: n 271 | }); 272 | }, W = (e, n) => { 273 | const s = w("leave", e); 274 | m(e, s, { 275 | duration: t.speed, 276 | complete: n 277 | }); 278 | }; 279 | function A() { 280 | r.value = r.value.filter((e) => e.state !== v.DESTROYED); 281 | } 282 | return Z(() => { 283 | y.on("add", N), y.on("close", O); 284 | }), tt(() => { 285 | y.off("add", N), y.off("close", O); 286 | }), () => { 287 | let e; 288 | return l("div", { 289 | class: "vue-notification-group", 290 | style: S.value 291 | }, [l(et, nt(L.value, { 292 | tag: "div", 293 | css: !g.value, 294 | name: t.animationName 295 | }), ft(e = c.value.map((n) => l("div", { 296 | key: n.id, 297 | class: "vue-notification-wrapper", 298 | style: V(n), 299 | "data-id": n.id, 300 | onMouseenter: () => C(n), 301 | onMouseleave: () => P(n) 302 | }, [i.body ? i.body({ 303 | item: n, 304 | class: [t.classes, n.type], 305 | close: () => d(n) 306 | }) : l("div", { 307 | class: Y(n), 308 | onClick: () => k(n) 309 | }, [t.dangerouslySetInnerHtml ? l(H, null, [n.title ? l("div", { 310 | class: "notification-title", 311 | innerHTML: n.title 312 | }, null) : null, l("div", { 313 | class: "notification-content", 314 | innerHTML: n.text 315 | }, null)]) : l(H, null, [n.title ? l("div", { 316 | class: "notification-title" 317 | }, [n.title]) : null, l("div", { 318 | class: "notification-content" 319 | }, [n.text])])])]))) ? e : { 320 | default: () => [e] 321 | })]); 322 | }; 323 | } 324 | }), E = (t) => { 325 | typeof t == "string" && (t = { title: "", text: t }), typeof t == "object" && y.emit("add", t); 326 | }; 327 | E.close = (t) => { 328 | y.emit("close", t); 329 | }; 330 | const gt = () => ({ notify: E }), pt = "Notifications"; 331 | function yt(t, o = {}) { 332 | Object.entries(o).forEach((a) => B.set(...a)); 333 | const i = o.name || "notify"; 334 | t.config.globalProperties["$" + i] = E, t.component(o.componentName || pt, dt); 335 | } 336 | const ht = { 337 | install: yt 338 | }; 339 | export { 340 | dt as Notifications, 341 | ht as default, 342 | E as notify, 343 | gt as useNotification 344 | }; 345 | -------------------------------------------------------------------------------- /src/components/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLAttributes, PropType, SlotsType, TransitionGroup, TransitionGroupProps, computed, defineComponent, onMounted, onUnmounted, ref } from 'vue'; 2 | import { params } from '@/params'; 3 | import { Id, listToDirection, emitter, parse } from '@/utils'; 4 | import defaults from '@/defaults'; 5 | import { NotificationItem, NotificationsOptions } from '@/types'; 6 | import { createTimer, NotificationItemWithTimer } from '@/utils/timer'; 7 | import './Notifications.css'; 8 | 9 | const STATE = { 10 | IDLE: 0, 11 | DESTROYED: 2, 12 | } as const; 13 | 14 | type NotificationItemState = typeof STATE; 15 | 16 | type NotificationItemExtended = NotificationItemWithTimer & { 17 | state: NotificationItemState[keyof NotificationItemState]; 18 | } 19 | 20 | export default defineComponent({ 21 | // eslint-disable-next-line vue/multi-word-component-names 22 | name: 'notifications', 23 | props: { 24 | group: { 25 | type: String, 26 | default: '', 27 | }, 28 | /** 29 | * Width of notification holder, can be `%`, `px` string or number. 30 | * @example '100%', '200px', 200 31 | * */ 32 | width: { 33 | type: [Number, String], 34 | default: 300, 35 | }, 36 | 37 | reverse: { 38 | type: Boolean, 39 | default: false, 40 | }, 41 | position: { 42 | type: [String, Array] as PropType, 43 | default: () => { 44 | return defaults.position; 45 | }, 46 | }, 47 | classes: { 48 | type: [String, Array] as PropType, 49 | default: 'vue-notification', 50 | }, 51 | 52 | animationType: { 53 | type: String as PropType<'css' | 'velocity'>, 54 | default: 'css', 55 | validator(value) { 56 | return value === 'css' || value === 'velocity'; 57 | }, 58 | }, 59 | 60 | animation: { 61 | type: Object as PropType>, 62 | default() { 63 | return defaults.velocityAnimation; 64 | }, 65 | }, 66 | 67 | animationName: { 68 | type: String, 69 | default: defaults.cssAnimation, 70 | }, 71 | speed: { 72 | type: Number, 73 | default: 300, 74 | }, 75 | /** Time (in ms) to keep the notification on screen (if **negative** - notification will stay **forever** or until clicked) */ 76 | duration: { 77 | type: Number, 78 | default: 3000, 79 | }, 80 | 81 | delay: { 82 | type: Number, 83 | default: 0, 84 | }, 85 | 86 | max: { 87 | type: Number, 88 | default: Infinity, 89 | }, 90 | 91 | ignoreDuplicates: { 92 | type: Boolean, 93 | default: false, 94 | }, 95 | 96 | closeOnClick: { 97 | type: Boolean, 98 | default: true, 99 | }, 100 | 101 | pauseOnHover: { 102 | type: Boolean, 103 | default: false, 104 | }, 105 | /** Use [v-html](https://vuejs.org/api/built-in-directives.html#v-html) to set `title` and `text` */ 106 | dangerouslySetInnerHtml: { 107 | type: Boolean, 108 | default: false, 109 | }, 110 | }, 111 | emits: { 112 | /* eslint-disable @typescript-eslint/no-unused-vars */ 113 | click: (item: NotificationItem) => true, 114 | destroy: (item: NotificationItem) => true, 115 | start: (item: NotificationItem) => true, 116 | /* eslint-enable @typescript-eslint/no-unused-vars */ 117 | }, 118 | slots: Object as SlotsType<{ 119 | body?: (props: { class: HTMLAttributes['class'], item: NotificationItem, close: () => void }) => any; 120 | }>, 121 | setup: (props, { emit, slots, expose }) => { 122 | const list = ref([]); 123 | const velocity = params.get('velocity'); 124 | 125 | const isVA = computed(() => { 126 | return props.animationType === 'velocity'; 127 | }); 128 | 129 | const active = computed(() => { 130 | return list.value.filter(v => v.state !== STATE.DESTROYED); 131 | }); 132 | 133 | const actualWidth = computed(() => { 134 | return parse(props.width); 135 | }); 136 | 137 | const styles = computed(() => { 138 | const { x, y } = listToDirection(props.position); 139 | const width = actualWidth.value.value; 140 | const suffix = actualWidth.value.type; 141 | 142 | // eslint-disable-next-line no-shadow 143 | const styles: Record = { 144 | width: width + suffix, 145 | }; 146 | 147 | if (y) { 148 | styles[y] = '0px'; 149 | } 150 | 151 | if (x) { 152 | if (x === 'center') { 153 | styles['left'] = `calc(50% - ${+width / 2}${suffix})`; 154 | } else { 155 | styles[x] = '0px'; 156 | } 157 | 158 | } 159 | 160 | return styles; 161 | }); 162 | 163 | const transitionGroupProps = computed(() => { 164 | if (!isVA.value) { 165 | return {}; 166 | } 167 | 168 | return { 169 | onEnter: handleEnter, 170 | onLeave: handleLeave, 171 | onAfterLeave: clean, 172 | }; 173 | }); 174 | 175 | const destroyIfNecessary = (item: NotificationItemExtended) => { 176 | emit('click', item); 177 | if (props.closeOnClick) { 178 | destroy(item); 179 | } 180 | }; 181 | 182 | const pauseTimeout = (item: NotificationItemExtended): undefined => { 183 | if (props.pauseOnHover) { 184 | item.timer?.stop(); 185 | } 186 | }; 187 | const resumeTimeout = (item: NotificationItemExtended): undefined => { 188 | if (props.pauseOnHover) { 189 | item.timer?.start(); 190 | } 191 | }; 192 | const addItem = (event: NotificationsOptions = {}): void => { 193 | event.group ||= ''; 194 | event.data ||= {}; 195 | 196 | if (props.group !== event.group) { 197 | return; 198 | } 199 | 200 | if (event.clean || event.clear) { 201 | destroyAll(); 202 | return; 203 | } 204 | 205 | const duration = typeof event.duration === 'number' 206 | ? event.duration 207 | : props.duration; 208 | 209 | const speed = typeof event.speed === 'number' 210 | ? event.speed 211 | : props.speed; 212 | 213 | const ignoreDuplicates = typeof event.ignoreDuplicates === 'boolean' 214 | ? event.ignoreDuplicates 215 | : props.ignoreDuplicates; 216 | 217 | const { title, text, type, data, id } = event; 218 | 219 | const item: NotificationItemExtended = { 220 | id: id || Id(), 221 | title, 222 | text, 223 | type, 224 | state: STATE.IDLE, 225 | speed, 226 | length: duration + 2 * speed, 227 | data, 228 | duplicates: 0, 229 | }; 230 | 231 | if (duration >= 0) { 232 | item.timer = createTimer(() => destroy(item), item.length); 233 | } 234 | 235 | const botToTop = 'bottom' in styles.value; 236 | const direction = props.reverse 237 | ? !botToTop 238 | : botToTop; 239 | 240 | let indexToDestroy = -1; 241 | 242 | const duplicate = active.value.find(i => { 243 | return i.title === event.title && i.text === event.text; 244 | }); 245 | 246 | if (ignoreDuplicates && duplicate) { 247 | duplicate.duplicates++; 248 | 249 | return; 250 | } 251 | 252 | if (direction) { 253 | list.value.push(item); 254 | emit('start', item); 255 | 256 | if (active.value.length > props.max) { 257 | indexToDestroy = 0; 258 | } 259 | } else { 260 | list.value.unshift(item); 261 | emit('start', item); 262 | 263 | if (active.value.length > props.max) { 264 | indexToDestroy = active.value.length - 1; 265 | } 266 | } 267 | 268 | if (indexToDestroy !== -1) { 269 | destroy(active.value[indexToDestroy]); 270 | } 271 | }; 272 | 273 | const closeItem = (id: unknown) => { 274 | destroyById(id); 275 | }; 276 | 277 | const notifyClass = (item: NotificationItemExtended): HTMLAttributes['class'] => { 278 | return [ 279 | 'vue-notification-template', 280 | props.classes, 281 | item.type || '', 282 | ]; 283 | }; 284 | 285 | const notifyWrapperStyle = (item: NotificationItemExtended) => { 286 | return isVA.value 287 | ? undefined 288 | : { transition: `all ${item.speed}ms` }; 289 | }; 290 | 291 | const destroy = (item: NotificationItemExtended): void => { 292 | item.timer?.stop(); 293 | item.state = STATE.DESTROYED; 294 | 295 | clean(); 296 | 297 | emit('destroy', item); 298 | }; 299 | 300 | const destroyById = (id: unknown): void=>{ 301 | const item = list.value.find(i => i.id === id); 302 | 303 | if (item) { 304 | destroy(item); 305 | } 306 | }; 307 | 308 | const destroyAll = (): void => { 309 | active.value.forEach(destroy); 310 | }; 311 | 312 | const getAnimation = (index: 'enter' | 'leave', el: Element)=> { 313 | const animation = props.animation?.[index]; 314 | 315 | return typeof animation === 'function' 316 | ? animation(el) 317 | : animation; 318 | }; 319 | 320 | const handleEnter = (el: Element, complete: () => void): void=> { 321 | const animation = getAnimation('enter', el); 322 | 323 | velocity(el, animation, { 324 | duration: props.speed, 325 | complete, 326 | }); 327 | }; 328 | 329 | const handleLeave = (el: Element, complete: () => void)=> { 330 | const animation = getAnimation('leave', el); 331 | 332 | velocity(el, animation, { 333 | duration: props.speed, 334 | complete, 335 | }); 336 | }; 337 | 338 | function clean() { 339 | list.value = list.value.filter(item => item.state !== STATE.DESTROYED); 340 | } 341 | 342 | 343 | onMounted(() => { 344 | emitter.on('add', addItem); 345 | emitter.on('close', closeItem); 346 | }); 347 | 348 | onUnmounted(() => { 349 | emitter.off('add', addItem); 350 | emitter.off('close', closeItem); 351 | }); 352 | 353 | if (import.meta.env.DEV) { 354 | expose({ 355 | list, 356 | addItem, 357 | }); 358 | } 359 | 360 | 361 | return () => ( 362 |
366 | 372 | { 373 | active.value.map((item) => { 374 | return ( 375 |
pauseTimeout(item)} 381 | onMouseleave={() => resumeTimeout(item)} 382 | > 383 | { 384 | slots.body ? slots.body({ 385 | item, 386 | class: [props.classes, item.type], 387 | close: () => destroy(item), 388 | }) : ( 389 |
destroyIfNecessary(item)} 392 | > 393 | { 394 | props.dangerouslySetInnerHtml ? 395 | ( 396 | <> 397 | {(item.title ? 398 |
: null)} 401 |
402 | 403 | ) 404 | : ( 405 | <> 406 | {(item.title ? 407 |
408 | { item.title } 409 |
: null)} 410 |
411 | { item.text } 412 |
413 | 414 | ) 415 | } 416 | 417 |
418 | ) 419 | } 420 |
421 | ); 422 | }) 423 | } 424 | 425 | 426 |
427 | ); 428 | }, 429 | }); 430 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/dm/@kyvg/vue3-notification)](https://www.npmjs.com/package/@kyvg/vue3-notification) 2 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/@kyvg/vue3-notification) 3 | 4 | ## Upgrade to v3.x 5 | ### Breaking changes 6 | - `title` and `text` no longer render with [`v-html`](https://vuejs.org/api/built-in-directives.html#v-html). Use `dangerouslySetInnerHtml` prop to render `title` and `text` with [`v-html`](https://vuejs.org/api/built-in-directives.html#v-html) 7 | 8 | # Vue.js notifications 9 | 10 | This is a fork and port of Vue 2 [vue-notifications](https://github.com/euvl/vue-notification) created by [euvl](https://github.com/euvl) to now support Vue 3. If you're using Vue 2.x use his version. 11 | 12 |

13 | 14 |

15 | 16 | ## Setup 17 | 18 | ```bash 19 | npm install --save @kyvg/vue3-notification 20 | 21 | yarn add @kyvg/vue3-notification 22 | ``` 23 | 24 | Add dependencies to your `main.js`: 25 | 26 | ```javascript 27 | import { createApp } from 'vue' 28 | import Notifications from '@kyvg/vue3-notification' 29 | 30 | const app = createApp({...}) 31 | app.use(Notifications) 32 | ``` 33 | 34 | Add the global component to your `App.vue`: 35 | 36 | ```vue 37 | 38 | ``` 39 | 40 | Please note that this library does not inherently support Nuxt 3. To enable compatibility with Nuxt 3, use the [`nuxt3-notifications`](https://github.com/windx-foobar/nuxt3-notifications) wrapper 41 | ## Usage 42 | 43 | Trigger notifications from your `.vue` files: 44 | 45 | ```javascript 46 | // simple 47 | this.$notify("Hello user!"); 48 | 49 | // using options 50 | this.$notify({ 51 | title: "Important message", 52 | text: "Hello user!", 53 | }); 54 | ``` 55 | 56 | Or trigger notifications from other files, for example, your router: 57 | 58 | ```javascript 59 | import { notify } from "@kyvg/vue3-notification"; 60 | 61 | notify({ 62 | title: "Authorization", 63 | text: "You have been logged in!", 64 | }); 65 | ``` 66 | 67 | Or use Composition API style: 68 | 69 | ```javascript 70 | import { useNotification } from "@kyvg/vue3-notification"; 71 | 72 | const { notify } = useNotification() 73 | 74 | notify({ 75 | title: "Authorization", 76 | text: "You have been logged in!", 77 | }); 78 | ``` 79 | ### Migration 80 | 81 | #### Vue 2.x syntax 82 | 83 | ```javascript 84 | Vue.notify({ 85 | title: "Vue 2 notification", 86 | }); 87 | ``` 88 | 89 | #### Vue 3.x syntax 90 | 91 | ```javascript 92 | import { notify } from "@kyvg/vue3-notification"; 93 | 94 | notify({ 95 | title: "Vue 3 notification 🎉", 96 | }); 97 | ``` 98 | 99 | #### Vue 3.x Composition API syntax 100 | 101 | ```javascript 102 | import { useNotification } from "@kyvg/vue3-notification"; 103 | 104 | const notification = useNotification() 105 | 106 | notification.notify({ 107 | title: "Vue 3 notification 🎉", 108 | }); 109 | ``` 110 | 111 | Also you can use [destructuring assignment](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment) 112 | ```javascript 113 | import { useNotification } from "@kyvg/vue3-notification"; 114 | 115 | const { notify } = useNotification() 116 | 117 | notify({ 118 | title: "Vue 3 notification 🎉", 119 | }); 120 | ``` 121 | 122 | ### Component props 123 | 124 | The majority of settings for the Notifications component are configured using props: 125 | 126 | ```vue 127 | 128 | ``` 129 | 130 | Note that all props are optional. 131 | 132 | | Name | Type | Default | Description | 133 | | ---------------- | ------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | 134 | | position | String/Array | 'top right' | Part of the screen where notifications will pop out | 135 | | width | Number/String | 300 | Width of notification holder, can be `%`, `px` string or number.
Valid values: '100%', '200px', 200 | 136 | | classes | String/Array | 'vue-notification' | List of classes that will be applied to notification element | 137 | | group | String | null | Name of the notification holder, if specified | 138 | | duration | Number | 3000 | Time (in ms) to keep the notification on screen (if **negative** - notification will stay **forever** or until clicked) | 139 | | speed | Number | 300 | Time (in ms) to show / hide notifications | 140 | | animation-type | String | 'css' | Type of animation, currently supported types are `css` and `velocity` | 141 | | animation-name | String | null | Animation name required for `css` animation | 142 | | animation | Object | Custom | Animation configuration for [Velocity](#Animation]) animation | 143 | | max | Number | Infinity | Maximum number of notifications that can be shown in notification holder | 144 | | reverse | Boolean | false | Show notifications in reverse order | 145 | | ignoreDuplicates | Boolean | false | Ignore repeated instances of the same notification | 146 | | closeOnClick | Boolean | true | Close notification when clicked | 147 | | pauseOnHover | Boolean | false | Keep the notification open while mouse hovers on notification | 148 | | dangerouslySetInnerHtml | Boolean | false | Use [v-html](https://vuejs.org/api/built-in-directives.html#v-html) to set `title` and `text` | 149 | 150 | ### Component events 151 | | Name | Type | Description | 152 | | ---------------- | -------------------------------- | -------------------------------------------- | 153 | | click | (item: NotificationItem) => void | The callback function that is triggered when notification was clicked 154 | | destroy | (item: NotificationItem) => void | The callback function that is triggered when notification was destroyes 155 | | start | (item: NotificationItem) => void | The callback function that is triggered when notification was appeared 156 | ### API 157 | 158 | Notifications are triggered via the API: 159 | 160 | ```javascript 161 | this.$notify({ 162 | // (optional) 163 | // Name of the notification holder 164 | group: 'foo', 165 | 166 | // (optional) 167 | // Title (will be wrapped in div.notification-title) 168 | title: 'This is the title', 169 | 170 | // Content (will be wrapped in div.notification-content) 171 | text: 'This is some content', 172 | 173 | // (optional) 174 | // Class that will be assigned to the notification 175 | type: 'warn', 176 | 177 | // (optional, override) 178 | // Time (in ms) to keep the notification on screen 179 | duration: 10000, 180 | 181 | // (optional, override) 182 | // Time (in ms) to show / hide notifications 183 | speed: 1000 184 | 185 | // (optional) 186 | // Data object that can be used in your template 187 | data: {} 188 | }) 189 | ``` 190 | 191 | To remove notifications, include the `clean: true` parameter. 192 | 193 | ```javascript 194 | this.$notify({ 195 | group: "foo", // clean only the foo group 196 | clean: true, 197 | }); 198 | ``` 199 | 200 | ### Plugin Options 201 | 202 | Configure the plugin itself using an additional options object: 203 | 204 | ```js 205 | app.use(Notifications, { name: "alert" }); 206 | ``` 207 | 208 | All options are optional: 209 | 210 | | Name | Type | Default | Description | 211 | | ------------- | ------ |----------------|-------------------------------------------------------------------------------| 212 | | name | String | notify | Defines the instance name. It's prefixed with the dollar sign. E.g. `$notify` | 213 | | componentName | String | Notifications | The component's name | 214 | | velocity | Object | undefined | A Velocity library object (see **Animation**) | 215 | 216 | > **Note**: setting `componentName` can cause issues when using SSR. 217 | 218 | ## TypeScript Support 219 | This library is written with TypeScript. Since the notification component is registered globally, you need to register its types. 220 | 221 | You can do this manually: 222 | ```ts 223 | import type { FunctionalComponent } from 'vue'; 224 | import type { Notifications } from '@kyvg/vue3-notification'; 225 | declare module 'vue' { 226 | export interface GlobalComponents { 227 | Notifications: FunctionalComponent; 228 | } 229 | } 230 | ``` 231 | Or, you can use built-in `unplugin-vue-components` resolver. This resolver allows you to seamlessly integrate this library with Vue projects using [`unplugin-vue-components`](https://github.com/unplugin/unplugin-vue-components). It automates the import of components, making your development process more efficient. 232 | 233 | ### Installation 234 | To get started, install the necessary packages using npm or yarn: 235 | ```bash 236 | npm install --save @kyvg/vue3-notification unplugin-vue-components 237 | # or 238 | yarn add @kyvg/vue3-notification unplugin-vue-components 239 | ``` 240 | ### Configuration 241 | To configure the resolver, update your Vue project's plugin settings. For example, in a Vite project, modify vite.config.js: 242 | ```js 243 | import Components from 'unplugin-vue-components/vite'; 244 | import NotificationsResolver from '@kyvg/vue3-notification/auto-import-resolver'; 245 | 246 | export default { 247 | plugins: [ 248 | Components({ 249 | resolvers: [NotificationsResolver()], 250 | }), 251 | ], 252 | } 253 | ``` 254 | Specify the custom component's name if you have configured it: 255 | ```js 256 | // main.js 257 | // ... 258 | app.use(Notifications, { componentName: "Alert" }); 259 | ``` 260 | Note that component name should be in PascalCase 261 | 262 | ```js 263 | import Components from 'unplugin-vue-components/vite'; 264 | import NotificationsResolver from '@kyvg/vue3-notification/auto-import-resolver'; 265 | 266 | export default { 267 | plugins: [ 268 | Components({ 269 | resolvers: [NotificationsResolver("Alert")], 270 | }), 271 | ], 272 | } 273 | ``` 274 | ## Features 275 | 276 | ### Position 277 | 278 | Position the component on the screen using the `position` prop: 279 | 280 | ```vue 281 | 282 | ``` 283 | 284 | It requires a `string` with **two keywords** for vertical and horizontal postion. 285 | 286 | Format: `" "`. 287 | 288 | - Horizontal options: `left`, `center`, `right` 289 | - Vertical options: `top`, `bottom` 290 | 291 | Default is `"top right"`. 292 | 293 | ### Width 294 | 295 | Width can be set using a `number` or `string` with optional `%` or `px` extensions: 296 | 297 | ```vue 298 | 299 | 300 | 301 | 302 | ``` 303 | 304 | ### Type 305 | 306 | Set the `type` of a notification (**warn**, **error**, **success**, etc) by adding a `type` property to the call: 307 | 308 | ```js 309 | this.$notify({ type: "success", text: "The operation completed" }); 310 | ``` 311 | 312 | This will add the `type` (i.e. "success") as a CSS class name to the `.vue-notification` element. 313 | 314 | See the [Styling](#styling) section for how to hook onto the class and style the popup. 315 | 316 | ### Groups 317 | 318 | For different classes of notifications, i.e... 319 | 320 | - authentication errors (top center) 321 | - app notifications (bottom-right) 322 | 323 | ...specify the `group` attribute: 324 | 325 | ```vue 326 | 327 | 328 | ``` 329 | 330 | Trigger a notification for a specific group by specifying it in the API call: 331 | 332 | ```javascript 333 | this.$notify({ group: "auth", text: "Wrong password, please try again" }); 334 | ``` 335 | 336 | ## Customisation 337 | 338 | ### Styling 339 | 340 | Vue Notifications comes with default styling, but it's easy to replace with your own. 341 | 342 | Specify one or more class hooks via the `classes` prop on the global component: 343 | 344 | ```vue 345 | 346 | ``` 347 | 348 | This will add the supplied class/classes to individual notification elements: 349 | 350 | ```html 351 |
352 |
353 |
Info
354 |
You have been logged in
355 |
356 |
357 | ``` 358 | 359 | Then include custom css rules to style the notifications: 360 | 361 | ```scss 362 | // style of the notification itself 363 | .my-notification { 364 | /*...*/ 365 | 366 | // style for title line 367 | .notification-title { 368 | /*...*/ 369 | } 370 | 371 | // style for content 372 | .notification-content { 373 | /*...*/ 374 | } 375 | 376 | // additional styling hook when using`type` parameter, i.e. this.$notify({ type: 'success', message: 'Yay!' }) 377 | &.success { 378 | /*...*/ 379 | } 380 | &.info { 381 | /*...*/ 382 | } 383 | &.error { 384 | /*...*/ 385 | } 386 | } 387 | ``` 388 | 389 | Note that the default rules are: 390 | 391 | ```scss 392 | .vue-notification { 393 | // styling 394 | margin: 0 5px 5px; 395 | padding: 10px; 396 | font-size: 12px; 397 | color: #ffffff; 398 | 399 | // default (blue) 400 | background: #44a4fc; 401 | border-left: 5px solid #187fe7; 402 | 403 | // types (green, amber, red) 404 | &.success { 405 | background: #68cd86; 406 | border-left-color: #42a85f; 407 | } 408 | 409 | &.warn { 410 | background: #ffb648; 411 | border-left-color: #f48a06; 412 | } 413 | 414 | &.error { 415 | background: #e54d42; 416 | border-left-color: #b82e24; 417 | } 418 | } 419 | ``` 420 | 421 | ### Content 422 | 423 | To completely replace notification content, use Vue's slots system: 424 | 425 | ```vue 426 | 427 | 438 | 439 | ``` 440 | 441 | The `props` object has the following members: 442 | 443 | | Name | Type | Description | 444 | | ----- | -------- | ------------------------------------ | 445 | | item | Object | Notification object | 446 | | close | Function | A function to close the notification | 447 | 448 | 449 | 450 | ### Animation 451 | 452 | Vue Notification can use the [Velocity](https://github.com/julianshapiro/velocity) library to power the animations using JavaScript. 453 | 454 | To use, manually install `velocity-animate` & pass the library to the `vue-notification` plugin (the reason for doing that is to reduce the size of this plugin). 455 | 456 | In your `main.js`: 457 | 458 | ```javascript 459 | import { createApp } from 'vue' 460 | import Notifications from '@kyvg/vue3-notification' 461 | import velocity from 'velocity-animate' 462 | 463 | const app = createApp({...}) 464 | app.use(Notifications, { velocity }) 465 | ``` 466 | 467 | In the template, set the `animation-type` prop: 468 | 469 | ```vue 470 | 471 | ``` 472 | 473 | The default configuration is: 474 | 475 | ```js 476 | { 477 | enter: { opacity: [1, 0] }, 478 | leave: { opacity: [0, 1] } 479 | } 480 | ``` 481 | 482 | To assign a custom animation, use the `animation` prop: 483 | 484 | ```vue 485 | 486 | ``` 487 | 488 | Note that `enter` and `leave` can be an `object` or a `function` that returns an `object`: 489 | 490 | ```javascript 491 | computed: { 492 | animation () { 493 | return { 494 | /** 495 | * Animation function 496 | * 497 | * Runs before animating, so you can take the initial height, width, color, etc 498 | * @param {HTMLElement} element The notification element 499 | */ 500 | enter (element) { 501 | let height = element.clientHeight 502 | return { 503 | // animates from 0px to "height" 504 | height: [height, 0], 505 | 506 | // animates from 0 to random opacity (in range between 0.5 and 1) 507 | opacity: [Math.random() * 0.5 + 0.5, 0] 508 | } 509 | }, 510 | leave: { 511 | height: 0, 512 | opacity: 0 513 | } 514 | } 515 | } 516 | } 517 | ``` 518 | 519 | ## Programmatically Closing 520 | 521 | ```javascript 522 | // You can use either a number or a string as a unique ID 523 | const id = Date.now(); 524 | const strId = 'custom-notification-42'; 525 | 526 | this.$notify({ 527 | id, 528 | text: 'This message will be removed immediately' 529 | }); 530 | 531 | this.$notify({ 532 | id: strId, 533 | text: 'This message will also be removed immediately' 534 | }); 535 | 536 | this.$notify.close(id); 537 | this.$notify.close(strId); 538 | ``` 539 | 540 | Or with composition API style: 541 | 542 | ```javascript 543 | import { useNotification } from "@kyvg/vue3-notification" 544 | 545 | const { notify } = useNotification(); 546 | 547 | // IDs can be numbers or strings 548 | const id = Date.now(); 549 | const strId = 'custom-notification-42'; 550 | 551 | notify({ id, text: 'Numeric ID example' }) 552 | notify({ id: strId, text: 'String ID example' }) 553 | 554 | notify.close(id); 555 | notify.close(strId); 556 | 557 | ``` 558 | 559 | ## FAQ 560 | 561 | Check closed issues with `FAQ` label to get answers for most asked questions. 562 | 563 | ## Development 564 | 565 | To contribute to the library: 566 | 567 | ```bash 568 | # build main library 569 | npm install 570 | npm run build 571 | 572 | # run tests 573 | npm run test 574 | 575 | # watch unit tests 576 | npm run unit:watch 577 | ``` 578 | -------------------------------------------------------------------------------- /test/unit/specs/Notifications.spec.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { describe, it, expect, vi } from 'vitest'; 3 | import { mount, config } from '@vue/test-utils'; 4 | import Notifications from '@/components/Notifications'; 5 | import Plugin from '@/index'; 6 | import { TransitionGroup } from 'vue'; 7 | 8 | describe('Notifications', () => { 9 | describe('defaults', () => { 10 | it('has correct default props', () => { 11 | const wrapper = mount(Notifications); 12 | const props = wrapper.props(); 13 | expect(props.width).toEqual(300); 14 | expect(props.reverse).toEqual(false); 15 | expect(props.position).toStrictEqual(['top', 'right']); 16 | expect(props.classes).toEqual('vue-notification'); 17 | expect(props.animationType).toEqual('css'); 18 | expect(props.animation?.enter).toBeDefined(); 19 | expect(props.animation?.leave).toBeDefined(); 20 | expect(props.speed).toEqual(300); 21 | expect(props.duration).toEqual(3000); 22 | expect(props.delay).toEqual(0); 23 | expect(props.ignoreDuplicates).toEqual(false); 24 | }); 25 | 26 | it('list is empty', () => { 27 | const wrapper = mount(Notifications); 28 | const items = wrapper.findAll('.vue-notification-wrapper'); 29 | expect(items.length).toBe(0); 30 | }); 31 | }); 32 | 33 | describe('methods', ()=> { 34 | describe('addItem', () => { 35 | describe('when no group', () => { 36 | it('adds item to list', () => { 37 | const wrapper = mount(Notifications); 38 | 39 | const event = { 40 | title: 'Title', 41 | text: 'Text', 42 | type: 'success', 43 | }; 44 | 45 | wrapper.vm.addItem(event); 46 | expect(wrapper.vm.list.length).toEqual(1); 47 | expect(wrapper.vm.list[0].id).toBeDefined(); 48 | expect(wrapper.vm.list[0].title).toEqual('Title'); 49 | expect(wrapper.vm.list[0].text).toEqual('Text'); 50 | expect(wrapper.vm.list[0].type).toEqual('success'); 51 | expect(wrapper.vm.list[0].state).toEqual(0); 52 | expect(wrapper.vm.list[0].speed).toEqual(300); 53 | expect(wrapper.vm.list[0].length).toEqual(3600); 54 | expect(wrapper.vm.list[0].timer).toBeDefined(); 55 | }); 56 | }); 57 | 58 | describe('when a group is defined and matches event group name', () => { 59 | it('adds item to list', () => { 60 | const props = { 61 | group: 'Group', 62 | }; 63 | 64 | const wrapper = mount(Notifications, { props }); 65 | const event = { 66 | group: 'Group', 67 | title: 'Title', 68 | text: 'Text', 69 | type: 'success', 70 | }; 71 | 72 | wrapper.vm.addItem(event); 73 | 74 | expect(wrapper.vm.list.length).toEqual(1); 75 | expect(wrapper.vm.list[0].id).toBeDefined(); 76 | expect(wrapper.vm.list[0].title).toEqual('Title'); 77 | expect(wrapper.vm.list[0].text).toEqual('Text'); 78 | expect(wrapper.vm.list[0].type).toEqual('success'); 79 | expect(wrapper.vm.list[0].state).toEqual(0); 80 | expect(wrapper.vm.list[0].speed).toEqual(300); 81 | expect(wrapper.vm.list[0].length).toEqual(3600); 82 | expect(wrapper.vm.list[0].timer).toBeDefined(); 83 | }); 84 | }); 85 | 86 | describe('when a group is defined and does not match event group name', () => { 87 | it('does not add item to list', () => { 88 | const props = { 89 | group: 'Does Not Match', 90 | }; 91 | 92 | const wrapper = mount(Notifications, { props }); 93 | const event = { 94 | group: 'Group', 95 | title: 'Title', 96 | text: 'Text', 97 | type: 'success', 98 | }; 99 | 100 | wrapper.vm.addItem(event); 101 | 102 | expect(wrapper.vm.list.length).toEqual(0); 103 | }); 104 | }); 105 | 106 | describe('item property overrides', () => { 107 | it('item length calculated from duration and speed props', () => { 108 | const duration = 50; 109 | const speed = 25; 110 | const expectedLength = duration + 2 * speed; 111 | 112 | const props = { 113 | duration, 114 | speed, 115 | }; 116 | 117 | const wrapper = mount(Notifications, { props }); 118 | 119 | const event = { 120 | title: 'Title', 121 | text: 'Text', 122 | type: 'success', 123 | }; 124 | 125 | wrapper.vm.addItem(event); 126 | 127 | expect(wrapper.vm.list.length).toEqual(1); 128 | expect(wrapper.vm.list[0].speed).toEqual(speed); 129 | expect(wrapper.vm.list[0].length).toEqual(expectedLength); 130 | }); 131 | }); 132 | 133 | describe('order of inserted items', () => { 134 | it('by default inserts items in reverse order', () => { 135 | const wrapper = mount(Notifications); 136 | 137 | const event1 = { 138 | title: 'First', 139 | }; 140 | 141 | const event2 = { 142 | title: 'Second', 143 | }; 144 | 145 | wrapper.vm.addItem(event1); 146 | wrapper.vm.addItem(event2); 147 | 148 | expect(wrapper.vm.list.length).toEqual(2); 149 | expect(wrapper.vm.list[0].title).toEqual('Second'); 150 | expect(wrapper.vm.list[1].title).toEqual('First'); 151 | }); 152 | 153 | it('when position is top and reverse is false, inserts in reverse order', () => { 154 | const props = { 155 | position: 'top right', 156 | reverse: false, 157 | }; 158 | 159 | const wrapper = mount(Notifications, { props }); 160 | 161 | const event1 = { 162 | title: 'First', 163 | }; 164 | 165 | const event2 = { 166 | title: 'Second', 167 | }; 168 | 169 | wrapper.vm.addItem(event1); 170 | wrapper.vm.addItem(event2); 171 | 172 | expect(wrapper.vm.list.length).toEqual(2); 173 | expect(wrapper.vm.list[0].title).toEqual('Second'); 174 | expect(wrapper.vm.list[1].title).toEqual('First'); 175 | }); 176 | 177 | it('when position is top and reverse is true, inserts in sequential order', () => { 178 | const props = { 179 | position: 'top right', 180 | reverse: true, 181 | }; 182 | 183 | const wrapper = mount(Notifications, { props }); 184 | 185 | const event1 = { 186 | title: 'First', 187 | }; 188 | 189 | const event2 = { 190 | title: 'Second', 191 | }; 192 | 193 | wrapper.vm.addItem(event1); 194 | wrapper.vm.addItem(event2); 195 | 196 | expect(wrapper.vm.list.length).toEqual(2); 197 | expect(wrapper.vm.list[0].title).toEqual('First'); 198 | expect(wrapper.vm.list[1].title).toEqual('Second'); 199 | }); 200 | 201 | it('when position is bottom and reverse is false, inserts in sequential order', () => { 202 | const props = { 203 | position: 'bottom right', 204 | reverse: false, 205 | }; 206 | 207 | const wrapper = mount(Notifications, { props }); 208 | 209 | const event1 = { 210 | title: 'First', 211 | }; 212 | 213 | const event2 = { 214 | title: 'Second', 215 | }; 216 | 217 | wrapper.vm.addItem(event1); 218 | wrapper.vm.addItem(event2); 219 | 220 | expect(wrapper.vm.list.length).toEqual(2); 221 | expect(wrapper.vm.list[0].title).toEqual('First'); 222 | expect(wrapper.vm.list[1].title).toEqual('Second'); 223 | }); 224 | 225 | it('when position is bottom and reverse is true, inserts in reverse order', () => { 226 | const props = { 227 | position: 'bottom right', 228 | reverse: true, 229 | }; 230 | 231 | const wrapper = mount(Notifications, { props }); 232 | const event1 = { 233 | title: 'First', 234 | }; 235 | 236 | const event2 = { 237 | title: 'Second', 238 | }; 239 | 240 | wrapper.vm.addItem(event1); 241 | wrapper.vm.addItem(event2); 242 | 243 | expect(wrapper.vm.list.length).toEqual(2); 244 | expect(wrapper.vm.list[0].title).toEqual('Second'); 245 | expect(wrapper.vm.list[1].title).toEqual('First'); 246 | }); 247 | }); 248 | 249 | describe('auto-destroy of items', () => { 250 | it('item is destroyed after certain duration', () => { 251 | const duration = 50; 252 | const speed = 25; 253 | const expectedLength = duration + 2 * speed; 254 | 255 | const props = { 256 | duration, 257 | speed, 258 | }; 259 | 260 | vi.useFakeTimers(); 261 | 262 | const wrapper = mount(Notifications, { props }); 263 | const event = { 264 | title: 'Title', 265 | text: 'Text', 266 | type: 'success', 267 | }; 268 | 269 | wrapper.vm.addItem(event); 270 | 271 | expect(wrapper.vm.list.length).toEqual(1); 272 | 273 | vi.advanceTimersByTime(expectedLength); 274 | 275 | expect(wrapper.vm.list.length).toEqual(0); 276 | }); 277 | }); 278 | 279 | describe('when ignoreDuplicates is on', () => { 280 | const props = { 281 | ignoreDuplicates: true, 282 | }; 283 | const wrapper = mount(Notifications, { props }); 284 | 285 | it('adds unique item to list', () => { 286 | const event = { 287 | title: 'Title', 288 | text: 'Text', 289 | type: 'success', 290 | }; 291 | 292 | wrapper.vm.addItem(event); 293 | 294 | expect(wrapper.vm.list.length).toEqual(1); 295 | expect(wrapper.vm.list[0].id).toBeDefined(); 296 | expect(wrapper.vm.list[0].title).toEqual('Title'); 297 | expect(wrapper.vm.list[0].text).toEqual('Text'); 298 | expect(wrapper.vm.list[0].type).toEqual('success'); 299 | expect(wrapper.vm.list[0].state).toEqual(0); 300 | expect(wrapper.vm.list[0].speed).toEqual(300); 301 | expect(wrapper.vm.list[0].length).toEqual(3600); 302 | expect(wrapper.vm.list[0].timer).toBeDefined(); 303 | }); 304 | }); 305 | 306 | it('does not add item with same title and text to list', () => { 307 | const wrapper = mount(Notifications); 308 | 309 | const event = { 310 | title: 'Title', 311 | text: 'Text', 312 | type: 'success', 313 | }; 314 | 315 | wrapper.vm.addItem(event); 316 | 317 | expect(wrapper.vm.list.length).toEqual(1); 318 | expect(wrapper.vm.list[0].id).toBeDefined(); 319 | expect(wrapper.vm.list[0].title).toEqual('Title'); 320 | expect(wrapper.vm.list[0].text).toEqual('Text'); 321 | expect(wrapper.vm.list[0].type).toEqual('success'); 322 | expect(wrapper.vm.list[0].state).toEqual(0); 323 | expect(wrapper.vm.list[0].speed).toEqual(300); 324 | expect(wrapper.vm.list[0].length).toEqual(3600); 325 | expect(wrapper.vm.list[0].timer).toBeDefined(); 326 | }); 327 | }); 328 | }); 329 | 330 | describe('rendering', () => { 331 | describe('notification wrapper', () => { 332 | it('adds notification item with correct title and text', async () => { 333 | const wrapper = mount(Notifications); 334 | 335 | const event = { 336 | title: 'Title', 337 | text: 'Text', 338 | type: 'success', 339 | }; 340 | 341 | wrapper.vm.addItem(event); 342 | 343 | await wrapper.vm.$nextTick(); 344 | 345 | const notifications = wrapper.findAll('.vue-notification-wrapper'); 346 | 347 | expect(notifications.length).toEqual(1); 348 | 349 | const title = wrapper.find('.notification-title').text(); 350 | expect(title).toEqual('Title'); 351 | 352 | const text = wrapper.find('.notification-content').text(); 353 | expect(text).toEqual('Text'); 354 | 355 | }); 356 | 357 | it('adds notification with correct inline styling', async () => { 358 | const wrapper = mount(Notifications); 359 | 360 | const event = { 361 | title: 'Title', 362 | text: 'Text', 363 | type: 'success', 364 | }; 365 | 366 | wrapper.vm.addItem(event); 367 | 368 | await wrapper.vm.$nextTick(); 369 | 370 | const notification = wrapper.get('.vue-notification-wrapper'); 371 | 372 | expect(notification.element.style.transition).toEqual('all 300ms'); 373 | 374 | }); 375 | 376 | it('adds the event type as css class body', async () => { 377 | const wrapper = mount(Notifications); 378 | 379 | const event = { 380 | title: 'Title', 381 | text: 'Text', 382 | type: 'success', 383 | }; 384 | 385 | wrapper.vm.addItem(event); 386 | 387 | await wrapper.vm.$nextTick(); 388 | 389 | const notification = wrapper.get('.vue-notification-wrapper > div'); 390 | 391 | expect(notification.classes()).toContain('vue-notification'); 392 | expect(notification.classes()).toContain('success'); 393 | 394 | }); 395 | 396 | it('has correct default body classes', async () => { 397 | const wrapper = mount(Notifications); 398 | 399 | const event = { 400 | title: 'Title', 401 | text: 'Text', 402 | type: 'success', 403 | }; 404 | 405 | wrapper.vm.addItem(event); 406 | 407 | await wrapper.vm.$nextTick(); 408 | 409 | const notification = wrapper.get('.vue-notification-wrapper > div'); 410 | 411 | expect(notification.classes()).toContain('vue-notification'); 412 | 413 | }); 414 | 415 | it('body classes can be customized via prop', async () => { 416 | const props = { 417 | classes: 'pizza taco-sushi', 418 | }; 419 | 420 | const wrapper = mount(Notifications, { props }); 421 | 422 | const event = { 423 | title: 'Title', 424 | text: 'Text', 425 | type: 'success', 426 | }; 427 | 428 | wrapper.vm.addItem(event); 429 | 430 | await wrapper.vm.$nextTick(); 431 | 432 | const notification = wrapper.get('.vue-notification-wrapper > div'); 433 | 434 | expect(notification.element).toBeDefined(); 435 | expect(notification.classes()).toContain('pizza'); 436 | expect(notification.classes()).toContain('taco-sushi'); 437 | 438 | }); 439 | }); 440 | 441 | describe('transition wrapper', () => { 442 | it('default is css transition', () => { 443 | const wrapper = mount(Notifications); 444 | 445 | expect(wrapper.findAllComponents(TransitionGroup).length).toEqual(1); 446 | expect(wrapper.findComponent(TransitionGroup).attributes().css).toEqual('true'); 447 | }); 448 | 449 | it('uses using velocity transition when enabled via prop', () => { 450 | const props = { 451 | animationType: 'velocity', 452 | }; 453 | 454 | const wrapper = mount(Notifications, { props }); 455 | 456 | expect(wrapper.findAllComponents(TransitionGroup).length).toEqual(1); 457 | expect(wrapper.findComponent(TransitionGroup).attributes().css).toEqual('false'); 458 | }); 459 | }); 460 | }); 461 | 462 | describe('features', () => { 463 | describe('pauseOnHover', () => { 464 | describe('when pauseOnHover is true', () => { 465 | const duration = 50; 466 | const speed = 25; 467 | 468 | const props = { 469 | pauseOnHover: true, 470 | duration, 471 | speed, 472 | }; 473 | 474 | it('pause timer', async () => { 475 | const wrapper = mount(Notifications, { props }); 476 | 477 | const event = { 478 | title: 'Title', 479 | text: 'Text', 480 | type: 'success', 481 | }; 482 | 483 | wrapper.vm.addItem(event); 484 | 485 | vi.useFakeTimers(); 486 | 487 | await wrapper.vm.$nextTick(); 488 | 489 | const [notification] = wrapper.findAll('.vue-notification-wrapper'); 490 | notification.trigger('mouseenter'); 491 | 492 | await vi.runAllTimersAsync(); 493 | 494 | expect(wrapper.vm.list.length).toBe(1); 495 | }); 496 | 497 | it('resume timer', async () => { 498 | const wrapper = mount(Notifications, { props }); 499 | 500 | const event = { 501 | title: 'Title', 502 | text: 'Text', 503 | type: 'success', 504 | }; 505 | vi.useFakeTimers(); 506 | 507 | wrapper.vm.addItem(event); 508 | 509 | await wrapper.vm.$nextTick(); 510 | 511 | const [notification] = wrapper.findAll('.vue-notification-wrapper'); 512 | notification.trigger('mouseenter'); 513 | await wrapper.vm.$nextTick(); 514 | notification.trigger('mouseleave'); 515 | 516 | await vi.runAllTimersAsync(); 517 | 518 | expect(wrapper.vm.list.length).toBe(0); 519 | }); 520 | 521 | it('pause exact notification', async () => { 522 | const wrapper = mount(Notifications, { props }); 523 | 524 | const event1 = { 525 | title: 'Title1', 526 | text: 'Text1', 527 | type: 'success', 528 | }; 529 | 530 | const event2 = { 531 | title: 'Title2', 532 | text: 'Text2', 533 | type: 'success', 534 | }; 535 | vi.useFakeTimers(); 536 | 537 | wrapper.vm.addItem(event1); 538 | wrapper.vm.addItem(event2); 539 | await wrapper.vm.$nextTick(); 540 | expect(wrapper.vm.list.length).toBe(2); 541 | 542 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 543 | const [_, notification] = wrapper.findAll('.vue-notification-wrapper'); 544 | notification.trigger('mouseenter'); 545 | 546 | await vi.runAllTimersAsync(); 547 | 548 | expect(wrapper.vm.list.length).toBe(1); 549 | expect(wrapper.vm.list[0].title).toBe('Title1'); 550 | }); 551 | }); 552 | 553 | describe('when pauseOnHover is false', () => { 554 | const duration = 50; 555 | const speed = 25; 556 | 557 | const props = { 558 | pauseOnHover: false, 559 | duration, 560 | speed, 561 | }; 562 | 563 | it('does not pause timer', async () => { 564 | const wrapper = mount(Notifications, { props }); 565 | 566 | const event = { 567 | title: 'Title', 568 | text: 'Text', 569 | type: 'success', 570 | }; 571 | 572 | wrapper.vm.addItem(event); 573 | 574 | vi.useFakeTimers(); 575 | 576 | await wrapper.vm.$nextTick(); 577 | 578 | const [notification] = wrapper.findAll('.vue-notification-wrapper'); 579 | notification.trigger('mouseenter'); 580 | 581 | await vi.runAllTimersAsync(); 582 | 583 | expect(wrapper.vm.list.length).toBe(0); 584 | }); 585 | 586 | }); 587 | }); 588 | }); 589 | 590 | describe('with velocity animation library', () => { 591 | const velocity = vi.fn(); 592 | config.global.plugins = [[Plugin, { velocity }]]; 593 | 594 | it('applies no additional inline styling to notification', async () => { 595 | const props = { 596 | animationType: 'velocity', 597 | }; 598 | 599 | const wrapper = mount(Notifications, { props }); 600 | 601 | const event = { 602 | title: 'Title', 603 | text: 'Text', 604 | type: 'success', 605 | }; 606 | 607 | wrapper.vm.addItem(event); 608 | 609 | await wrapper.vm.$nextTick(); 610 | 611 | const notification = wrapper.get('.vue-notification-wrapper'); 612 | 613 | expect(notification.element.style.transition).toEqual(''); 614 | }); 615 | 616 | it('adds item to list', () => { 617 | const props = { 618 | animationType: 'velocity', 619 | }; 620 | 621 | const wrapper = mount(Notifications, { props }); 622 | 623 | const event = { 624 | title: 'Title', 625 | text: 'Text', 626 | type: 'success', 627 | }; 628 | 629 | wrapper.vm.addItem(event); 630 | 631 | // expect(wrapper.vm.componentName).toEqual('velocity-group'); 632 | expect(wrapper.vm.list.length).toEqual(1); 633 | expect(wrapper.vm.list[0].id).toBeDefined(); 634 | expect(wrapper.vm.list[0].title).toEqual('Title'); 635 | expect(wrapper.vm.list[0].text).toEqual('Text'); 636 | expect(wrapper.vm.list[0].type).toEqual('success'); 637 | expect(wrapper.vm.list[0].state).toEqual(0); 638 | expect(wrapper.vm.list[0].speed).toEqual(300); 639 | expect(wrapper.vm.list[0].length).toEqual(3600); 640 | expect(wrapper.vm.list[0].timer).toBeDefined(); 641 | }); 642 | }); 643 | }); 644 | --------------------------------------------------------------------------------