├── .github ├── FUNDING.yml └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── astro-playground ├── .gitignore ├── astro.config.mjs ├── package.json ├── public │ └── favicon.svg ├── src │ ├── components │ │ ├── AstroComponent.astro │ │ ├── Notivue.vue │ │ ├── ReactComponent.tsx │ │ └── VueComponent.vue │ ├── env.d.ts │ ├── layouts │ │ └── Layout.astro │ ├── pages │ │ ├── _app.ts │ │ ├── about.astro │ │ └── index.astro │ └── styles │ │ └── reset.css └── tsconfig.json ├── package.json ├── packages └── notivue │ ├── .gitignore │ ├── Notifications │ ├── Notification.vue │ ├── NotificationProgress.vue │ ├── constants.ts │ ├── icons │ │ ├── CloseIcon.vue │ │ ├── ErrorIcon.vue │ │ ├── ErrorOutlineIcon.vue │ │ ├── InfoIcon.vue │ │ ├── InfoOutlineIcon.vue │ │ ├── PromiseIcon.vue │ │ ├── SuccessIcon.vue │ │ ├── SuccessOutlineIcon.vue │ │ └── index.ts │ ├── notifications-progress.css │ ├── notifications.css │ ├── themes.ts │ └── types.ts │ ├── Notivue │ ├── AriaLive.vue │ ├── Notivue.vue │ ├── NotivueImpl.vue │ ├── composables │ │ ├── useMouseEvents.ts │ │ ├── useNotivueStyles.ts │ │ ├── useReducedMotion.ts │ │ ├── useResizeListObserver.ts │ │ ├── useSizes.ts │ │ ├── useTouchEvents.ts │ │ ├── useWindowFocus.ts │ │ └── useWindowSize.ts │ ├── constants.ts │ ├── types.ts │ └── utils.ts │ ├── NotivueKeyboard │ ├── NotivueKeyboard.vue │ ├── NotivueKeyboardImpl.vue │ ├── constants.ts │ ├── types.ts │ ├── useKeyboardFocus.ts │ ├── useLastFocused.ts │ └── useNotivueKeyboard.ts │ ├── NotivueSwipe │ ├── NotivueSwipe.vue │ ├── constants.ts │ └── types.ts │ ├── astro │ ├── Notivue.vue │ ├── createNotivue.ts │ ├── push.ts │ └── types.ts │ ├── core │ ├── animations.css │ ├── constants.ts │ ├── createInstance.ts │ ├── createNotivue.ts │ ├── createPush.ts │ ├── createStore.ts │ ├── createStoreWatchers.ts │ ├── symbols.ts │ ├── types.ts │ ├── useStore.ts │ └── utils.ts │ ├── index.ts │ ├── nuxt │ ├── README.md │ ├── index.d.ts │ ├── module.cjs │ ├── module.d.ts │ ├── module.json │ └── module.mjs │ ├── package.json │ ├── scripts │ ├── verify-exports.js │ └── verify-tarball.sh │ ├── shared │ ├── ClientOnly.ts │ └── exports.js │ ├── tsconfig.json │ └── vite.config.ts ├── playground ├── .gitignore ├── .npmrc ├── app.vue ├── assets │ ├── inter-v13-latin-500.woff2 │ ├── inter-v13-latin-700.woff2 │ ├── profile-picture.jpg │ ├── pt-sans-narrow-v17-latin-700.woff2 │ ├── pt-sans-narrow-v17-latin-regular.woff2 │ └── style.css ├── components │ ├── custom-notifications │ │ ├── FriendRequestNotification.vue │ │ ├── SimpleNotification.vue │ │ └── UploadNotification.vue │ ├── icons │ │ ├── ArrowIcon.vue │ │ ├── CloseIcon.vue │ │ ├── CustomIcon.vue │ │ ├── DestroyIcon.vue │ │ ├── DismissIcon.vue │ │ ├── InfoIcon.vue │ │ ├── PromiseIcon.vue │ │ ├── SuccessIcon.vue │ │ ├── VueIcon.vue │ │ └── WarnIcon.vue │ ├── nav │ │ ├── Nav.vue │ │ ├── NavActions.vue │ │ ├── NavNotificationsCustomization.vue │ │ ├── NavNotificationsThemes.vue │ │ ├── NavNotivueConfig.vue │ │ ├── NavNotivuePosition.vue │ │ ├── NavPushBuiltIn.vue │ │ └── NavPushHeadless.vue │ └── shared │ │ ├── Background.server.vue │ │ ├── Button.vue │ │ ├── ButtonGroup.vue │ │ └── QueueCount.vue ├── middleware │ └── push.global.ts ├── nuxt.config.ts ├── package.json ├── plugins │ └── store.ts ├── public │ ├── icon.svg │ └── og-image.jpg ├── tsconfig.json └── utils │ ├── date.ts │ ├── head.ts │ ├── misc.ts │ └── store.ts ├── pnpm-workspace.yaml └── tests ├── .gitignore ├── Notifications ├── accessibility.cy.ts ├── close-button.cy.ts ├── components │ └── Notivue.vue ├── elements.cy.ts └── themes.cy.ts ├── Notivue ├── accessibility.cy.ts ├── attributes.cy.ts ├── components │ └── Notivue.vue ├── config-animations.cy.ts ├── config-duplicates.cy.ts ├── config-enqueue.cy.ts ├── config-limit.cy.ts ├── config-pause-on-hover.cy.ts ├── config-pause-on-touch.cy.ts ├── config-teleport.cy.ts ├── instance.cy.ts ├── prefers-reduced-motion.cy.ts ├── push-callbacks.cy.ts ├── push-methods.cy.ts ├── push-specific-options.cy.ts ├── slot-callbacks.cy.ts ├── slot-custom-push-options.cy.ts ├── slot-custom-push-props.cy.ts ├── slot-default-options.cy.ts ├── slot-global-options.cy.ts ├── slot-internal-properties.cy.ts └── transitions.cy.ts ├── NotivueKeyboard ├── actions.cy.ts ├── components │ ├── Candidate.vue │ ├── Notivue.vue │ └── Unqualified.vue ├── entering-stream.cy.ts ├── leaving-stream.cy.ts ├── props.cy.ts └── queue.cy.ts ├── NotivueSwipe ├── clear.cy.ts ├── components │ └── Notivue.vue ├── debounce.cy.ts ├── props.cy.ts ├── styles.cy.ts └── timeouts.cy.ts ├── config └── update-config.test.ts ├── cypress.config.ts ├── cypress └── support │ ├── commands-keyboard.ts │ ├── commands-notifications.ts │ ├── commands-notivue.ts │ ├── commands-swipe.ts │ ├── component-index.html │ ├── component.ts │ ├── styles.css │ └── utils.ts ├── package.json ├── shared-config.ts ├── tsconfig.json └── vite.config.mts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | polar: smastrom 2 | buy_me_a_coffee: smastrom 3 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: ['v*'] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | tests-workflow: 10 | uses: ./.github/workflows/tests.yml 11 | publish: 12 | needs: tests-workflow 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | registry-url: 'https://registry.npmjs.org' 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | with: 26 | version: 8 27 | run_install: true 28 | - name: Build Notivue 29 | run: pnpm -C packages/notivue run build 30 | - name: Copy README and LICENSE 31 | run: cp README.md LICENSE packages/notivue 32 | - name: Pack and verify 33 | run: cd packages/notivue && rm -rf *.tgz && npm pack && ./scripts/verify-tarball.sh 34 | - name: Publish 35 | run: cd packages/notivue && npm publish *.tgz --provenance 36 | env: 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | tags-ignore: 11 | - '*' 12 | workflow_call: 13 | 14 | jobs: 15 | cypress-run: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Install Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 20 23 | - uses: pnpm/action-setup@v2 24 | name: Install pnpm 25 | with: 26 | version: 8 27 | run_install: false 28 | - name: Install Cypress binaries 29 | run: npx cypress install 30 | - name: Install dependecies 31 | run: pnpm install 32 | - name: Build Notivue 33 | run: pnpm build 34 | - name: Install Notivue 35 | run: pnpm install 36 | - name: Test with Vitest 37 | run: pnpm test:unit 38 | - name: Test with Cypress 39 | uses: cypress-io/github-action@v5 40 | timeout-minutes: 15 41 | with: 42 | env: CYPRESS_CI=true 43 | component: true 44 | install: false 45 | working-directory: tests 46 | browser: chrome 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs 2 | *.log 3 | pnpm-debug.log* 4 | 5 | .vscode 6 | .idea 7 | .DS_Store 8 | 9 | node_modules 10 | dist 11 | 12 | notivue-*.tgz 13 | 14 | .nuxt 15 | pnpm-lock.yaml 16 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 3, 6 | "trailingComma": "es5", 7 | "useTabs": false, 8 | "plugins": ["prettier-plugin-astro"], 9 | "overrides": [ 10 | { 11 | "files": "README.md", 12 | "options": { 13 | "tabWidth": 2, 14 | "trailingComma": "none", 15 | "printWidth": 90 16 | } 17 | }, { 18 | "files": "*.astro", 19 | "options": { 20 | "parser": "astro" 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Please refer to [smastrom/contributing](https://github.com/smastrom/contributing/blob/main/README.md). 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-present Simone Mastromattei 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /astro-playground/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # environment variables 16 | .env 17 | .env.production 18 | 19 | # macOS-specific files 20 | .DS_Store 21 | -------------------------------------------------------------------------------- /astro-playground/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config' 2 | 3 | import vue from '@astrojs/vue' 4 | import react from '@astrojs/react' 5 | 6 | export default defineConfig({ 7 | integrations: [ 8 | vue({ 9 | appEntrypoint: '/src/pages/_app.ts', 10 | devtools: true, 11 | }), 12 | react(), 13 | ], 14 | vite: { 15 | optimizeDeps: { 16 | include: ['notivue'], 17 | }, 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /astro-playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notivue-astro-playground", 3 | "type": "module", 4 | "private": true, 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/react": "^3.6.2", 14 | "@astrojs/vue": "^4.5.0", 15 | "astro": "^4.15.1", 16 | "notivue": "workspace:*", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1", 19 | "vue": "^3.4.30" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.3.3", 23 | "@types/react-dom": "^18.3.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /astro-playground/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /astro-playground/src/components/AstroComponent.astro: -------------------------------------------------------------------------------- 1 |
2 |

From a Script Tag

3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 60 | -------------------------------------------------------------------------------- /astro-playground/src/components/Notivue.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /astro-playground/src/components/ReactComponent.tsx: -------------------------------------------------------------------------------- 1 | import { push } from 'notivue/astro' 2 | 3 | function pushStatic() { 4 | push.info({ 5 | title: 'React Notification', 6 | message: 'Notification from React!', 7 | }) 8 | } 9 | 10 | function pushPromise() { 11 | const promise = push.promise({ 12 | title: 'Loading React Notification...', 13 | message: 'Loading notification from React...', 14 | }) 15 | 16 | setTimeout(() => { 17 | promise.resolve({ 18 | title: 'React Notification', 19 | message: 'Loaded notification from React!', 20 | }) 21 | }, 2000) 22 | } 23 | 24 | export function ReactComponent() { 25 | return ( 26 |
27 |

From React

28 | 29 | 30 | 31 | 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /astro-playground/src/components/VueComponent.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 36 | -------------------------------------------------------------------------------- /astro-playground/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /astro-playground/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { ViewTransitions } from 'astro:transitions' 3 | 4 | import Notivue from '@/components/Notivue.vue' 5 | 6 | import 'notivue/astro/notification.css' 7 | import 'notivue/astro/animations.css' 8 | 9 | import '@/styles/reset.css' 10 | --- 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /astro-playground/src/pages/_app.ts: -------------------------------------------------------------------------------- 1 | import { createNotivue } from 'notivue/astro' 2 | 3 | import type { App, Plugin } from 'vue' 4 | 5 | const notivue = createNotivue({ 6 | teleportTo: '#notivue_teleport', 7 | }) 8 | 9 | export default (app: App) => { 10 | app.use(notivue as unknown as Plugin) 11 | } 12 | -------------------------------------------------------------------------------- /astro-playground/src/pages/about.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/Layout.astro' 3 | 4 | import VueComponent from '@/components/VueComponent.vue' 5 | import AstroComponent from '@/components/AstroComponent.astro' 6 | import { ReactComponent } from '@/components/ReactComponent' 7 | --- 8 | 9 | 10 | Home 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /astro-playground/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../layouts/Layout.astro' 3 | 4 | import VueComponent from '../components/VueComponent.vue' 5 | import AstroComponent from '@/components/AstroComponent.astro' 6 | import { ReactComponent } from '../components/ReactComponent' 7 | --- 8 | 9 | 10 | About 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /astro-playground/src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* https://andy-bell.co.uk/a-more-modern-css-reset/ */ 2 | 3 | /* Box sizing rules */ 4 | *, 5 | *::before, 6 | *::after { 7 | box-sizing: border-box; 8 | } 9 | 10 | html { 11 | font-family: system-ui, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 12 | 'Segoe UI Emoji'; 13 | } 14 | 15 | /* Prevent font size inflation */ 16 | html { 17 | -moz-text-size-adjust: none; 18 | -webkit-text-size-adjust: none; 19 | text-size-adjust: none; 20 | } 21 | 22 | /* Remove default margin in favour of better control in authored CSS */ 23 | body, 24 | h1, 25 | h2, 26 | h3, 27 | h4, 28 | p, 29 | figure, 30 | blockquote, 31 | dl, 32 | dd { 33 | margin: 0; 34 | } 35 | 36 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 37 | ul[role='list'], 38 | ol[role='list'] { 39 | list-style: none; 40 | } 41 | 42 | /* Set core body defaults */ 43 | body { 44 | min-height: 100vh; 45 | line-height: 1.5; 46 | } 47 | 48 | /* Set shorter line heights on headings and interactive elements */ 49 | h1, 50 | h2, 51 | h3, 52 | h4, 53 | button, 54 | input, 55 | label { 56 | line-height: 1.1; 57 | } 58 | 59 | /* Balance text wrapping on headings */ 60 | h1, 61 | h2, 62 | h3, 63 | h4 { 64 | text-wrap: balance; 65 | } 66 | 67 | /* A elements that don't have a class get default styles */ 68 | a:not([class]) { 69 | text-decoration-skip-ink: auto; 70 | color: currentColor; 71 | } 72 | 73 | /* Make images easier to work with */ 74 | img, 75 | picture { 76 | max-width: 100%; 77 | display: block; 78 | } 79 | 80 | /* Inherit fonts for inputs and buttons */ 81 | input, 82 | button, 83 | textarea, 84 | select { 85 | font: inherit; 86 | } 87 | 88 | /* Make sure textareas without a rows attribute are not tiny */ 89 | textarea:not([rows]) { 90 | min-height: 10em; 91 | } 92 | 93 | /* Anything that has been anchored to should have extra scroll margin */ 94 | :target { 95 | scroll-margin-block: 5ex; 96 | } 97 | -------------------------------------------------------------------------------- /astro-playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["src/*"] 7 | }, 8 | "jsx": "react-jsx" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notivue-monorepo", 3 | "private": true, 4 | "packageManager": "pnpm@8.14.3", 5 | "engines": { 6 | "node": ">=20.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "pnpm build && concurrently \"pnpm -C packages/notivue run watch\" \"pnpm -C playground install && pnpm -C playground run dev --host\"", 10 | "dev:astro": "pnpm build && concurrently \"pnpm -C packages/notivue run watch\" \"pnpm -C astro-playground install && pnpm -C astro-playground run dev --host\"", 11 | "build": "pnpm -C packages/notivue run build", 12 | "build:playground": "pnpm build && pnpm install && pnpm -C playground run build", 13 | "test": "pnpm build && pnpm install && pnpm -C tests run test", 14 | "test:gui": "pnpm build && concurrently \"pnpm -C packages/notivue run watch\" \"pnpm -C tests install && pnpm -C tests run test:gui\"", 15 | "test:unit": "pnpm -C tests run test:unit", 16 | "prepare": "husky" 17 | }, 18 | "devDependencies": { 19 | "concurrently": "^8.2.2", 20 | "husky": "^9.1.5", 21 | "lint-staged": "^15.2.9", 22 | "prettier": "^3.3.3", 23 | "prettier-plugin-astro": "^0.14.1" 24 | }, 25 | "lint-staged": { 26 | "*.{js,ts,vue,json,css,md}": "prettier --write" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/notivue/.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | !/nuxt/README.md 3 | 4 | LICENSE 5 | 6 | vite.config.ts.timestamp* 7 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/Notification.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 62 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/NotificationProgress.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 24 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/constants.ts: -------------------------------------------------------------------------------- 1 | import { CLASS_PREFIX as CX } from '@/core/constants' 2 | import { filledIcons } from './icons' 3 | import { lightTheme } from './themes' 4 | 5 | export const Classes = { 6 | NOTIFICATION: CX + 'notification', 7 | ICON: CX + 'icon', 8 | CONTENT: CX + 'content', 9 | TITLE: CX + 'content-title', 10 | MESSAGE: CX + 'content-message', 11 | CLOSE: CX + 'close', 12 | CLOSE_ICON: CX + 'close-icon', 13 | TRANSITION: CX + 'transition', 14 | PROGRESS: CX + 'progress', 15 | DUPLICATE: CX + 'duplicate', 16 | } 17 | 18 | export const DEFAULT_NOTIFICATIONS_PROPS = { 19 | icons: () => filledIcons, 20 | theme: () => lightTheme, 21 | hideClose: false, 22 | closeAriaLabel: 'Close', 23 | } as const 24 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/ErrorIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/ErrorOutlineIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/InfoIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/InfoOutlineIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/PromiseIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 17 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/SuccessIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/SuccessOutlineIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 15 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/icons/index.ts: -------------------------------------------------------------------------------- 1 | import { markRaw as raw, type SVGAttributes } from 'vue' 2 | 3 | import { NotificationTypeKeys as NType } from '@/core/constants' 4 | 5 | import SuccessIcon from './SuccessIcon.vue' 6 | import ErrorIcon from './ErrorIcon.vue' 7 | import InfoIcon from './InfoIcon.vue' 8 | import SuccessOutlineIcon from './SuccessOutlineIcon.vue' 9 | import ErrorOutlineIcon from './ErrorOutlineIcon.vue' 10 | import InfoOutlineIcon from './InfoOutlineIcon.vue' 11 | import PromiseIcon from './PromiseIcon.vue' 12 | import CloseIcon from './CloseIcon.vue' 13 | 14 | import type { NotivueIcons } from 'notivue' 15 | 16 | export const svgProps: SVGAttributes = { 17 | xmlns: 'http://www.w3.org/2000/svg', 18 | viewBox: '0 0 24 24', 19 | 'aria-hidden': 'true', 20 | } 21 | 22 | export const ionProps: SVGAttributes = { 23 | ...svgProps, 24 | fill: 'currentColor', 25 | viewBox: '0 0 12 12', 26 | } 27 | 28 | export const featherProps: SVGAttributes = { 29 | ...svgProps, 30 | stroke: 'currentColor', 31 | 'stroke-width': 2, 32 | 'stroke-linecap': 'round', 33 | 'stroke-linejoin': 'round', 34 | } 35 | 36 | export const filledIcons: NotivueIcons = { 37 | [NType.SUCCESS]: raw(SuccessIcon), 38 | [NType.ERROR]: raw(ErrorIcon), 39 | [NType.INFO]: raw(InfoIcon), 40 | [NType.WARNING]: raw(ErrorIcon), 41 | [NType.PROMISE]: raw(PromiseIcon), 42 | [NType.PROMISE_RESOLVE]: raw(SuccessIcon), 43 | [NType.PROMISE_REJECT]: raw(ErrorIcon), 44 | close: raw(CloseIcon), 45 | } 46 | 47 | export const outlinedIcons: NotivueIcons = { 48 | [NType.SUCCESS]: raw(SuccessOutlineIcon), 49 | [NType.ERROR]: raw(ErrorOutlineIcon), 50 | [NType.INFO]: raw(InfoOutlineIcon), 51 | [NType.WARNING]: raw(ErrorOutlineIcon), 52 | [NType.PROMISE]: raw(PromiseIcon), 53 | [NType.PROMISE_RESOLVE]: raw(SuccessOutlineIcon), 54 | [NType.PROMISE_REJECT]: raw(ErrorOutlineIcon), 55 | close: raw(CloseIcon), 56 | } 57 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/notifications-progress.css: -------------------------------------------------------------------------------- 1 | @media (prefers-reduced-motion: no-preference) { 2 | .Notivue__notification:has(.Notivue__progress) { 3 | border-radius: var(--nv-radius, 0) var(--nv-radius, 0) var(--nv-radius, 0) 0; 4 | 5 | & .Notivue__content-message { 6 | margin-bottom: var(--nv-progress-height, 4px); 7 | } 8 | } 9 | 10 | .Notivue__progress { 11 | position: absolute; 12 | bottom: 0; 13 | left: 0; 14 | width: 100%; 15 | height: var(--nv-progress-height, 4px); 16 | background-color: var(--nv-accent); 17 | animation: Notivue__progress-kf var(--nv-anim-dur) linear forwards; 18 | transform-origin: left; 19 | border-radius: 0 var(--nv-radius, 0) var(--nv-radius, 0) 0; 20 | } 21 | 22 | [dir='rtl'] { 23 | & .Notivue__progress { 24 | transform-origin: right; 25 | border-radius: var(--nv-radius, 0) 0 0 var(--nv-radius, 0); 26 | } 27 | 28 | & .Notivue__notification:has(.Notivue__progress) { 29 | border-radius: var(--nv-radius, 0) var(--nv-radius, 0) 0 var(--nv-radius, 0); 30 | } 31 | } 32 | 33 | @keyframes Notivue__progress-kf { 34 | 0% { 35 | transform: scaleX(1); 36 | } 37 | 100% { 38 | transform: scaleX(0); 39 | } 40 | } 41 | } 42 | 43 | @media (prefers-reduced-motion: reduce) { 44 | .Notivue__progress { 45 | display: none; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/themes.ts: -------------------------------------------------------------------------------- 1 | import type { NotivueTheme } from 'notivue' 2 | 3 | const layout: NotivueTheme = { 4 | '--nv-width': '350px', 5 | '--nv-spacing': '0.625rem', 6 | '--nv-radius': '0.625rem', 7 | '--nv-icon-size': '1.25rem', 8 | '--nv-title-size': '0.925rem', 9 | '--nv-message-size': '0.925rem', 10 | '--nv-y-align': 'center', 11 | } 12 | 13 | const shadow = { 14 | '--nv-shadow': 'rgba(0, 0, 0, 0.06) 0px 4px 6px -1px, rgba(0, 0, 0, 0.03) 0px 2px 4px -1px', 15 | } 16 | 17 | export const lightTheme: NotivueTheme = { 18 | ...layout, 19 | ...shadow, 20 | 21 | '--nv-global-bg': '#FFF', 22 | '--nv-global-fg': '#171717', 23 | 24 | '--nv-success-accent': '#28B780', 25 | '--nv-error-accent': '#E74C3C', 26 | '--nv-warning-accent': '#F59E0B', 27 | '--nv-info-accent': '#3E8EFF', 28 | '--nv-promise-accent': '#171717', 29 | } 30 | 31 | export const pastelTheme: NotivueTheme = { 32 | ...layout, 33 | ...shadow, 34 | 35 | '--nv-success-bg': '#E9FAEF', 36 | '--nv-success-accent': '#059669', 37 | '--nv-success-fg': '#057452', 38 | 39 | '--nv-error-bg': '#FEEFEF', 40 | '--nv-error-accent': '#E6523C', 41 | '--nv-error-fg': '#C5412C', 42 | 43 | '--nv-warning-bg': '#FFF0D8', 44 | '--nv-warning-accent': '#F48533', 45 | '--nv-warning-fg': '#81471D', 46 | 47 | '--nv-info-bg': '#DEF0FA', 48 | '--nv-info-accent': '#1F70AC', 49 | '--nv-info-fg': '#1F70AC', 50 | 51 | '--nv-promise-bg': '#FFF', 52 | '--nv-promise-accent': '#334155', 53 | '--nv-promise-fg': '#334155', 54 | } 55 | 56 | export const materialTheme: NotivueTheme = { 57 | ...layout, 58 | ...shadow, 59 | 60 | '--nv-global-accent': '#FFF', 61 | '--nv-global-fg': '#FFF', 62 | 63 | '--nv-success-bg': '#178570', 64 | '--nv-error-bg': '#C94430', 65 | '--nv-info-bg': '#117AAE', 66 | 67 | '--nv-warning-bg': '#FFE556', 68 | '--nv-warning-fg': '#4F5358', 69 | '--nv-warning-accent': '#4F5358', 70 | 71 | '--nv-promise-bg': '#FFF', 72 | '--nv-promise-fg': '#334155', 73 | '--nv-promise-accent': '#64748B', 74 | } 75 | 76 | export const darkTheme: NotivueTheme = { 77 | ...layout, 78 | '--nv-border-width': '1px', 79 | 80 | '--nv-global-bg': '#1F1F1F', 81 | '--nv-global-border': '#414141', 82 | '--nv-global-fg': '#D0D0D0', 83 | 84 | '--nv-success-accent': '#8EF997', 85 | '--nv-error-accent': '#FF7777', 86 | '--nv-warning-accent': '#FFE554', 87 | '--nv-info-accent': '#5FD4FF', 88 | '--nv-promise-accent': '#D0D0D0', 89 | } 90 | 91 | export const slateTheme: NotivueTheme = { 92 | ...layout, 93 | '--nv-border-width': '1px', 94 | 95 | '--nv-global-bg': '#20252E', 96 | '--nv-global-border': '#353b45', 97 | '--nv-global-fg': '#dfdfdf', 98 | 99 | '--nv-success-accent': '#34D399', 100 | '--nv-error-accent': '#FF7777', 101 | '--nv-warning-accent': '#FFE554', 102 | '--nv-info-accent': '#5FD4FF', 103 | '--nv-promise-accent': '#D0D0D0', 104 | } 105 | -------------------------------------------------------------------------------- /packages/notivue/Notifications/types.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from 'vue' 2 | 3 | import type { NotificationType, NotivueItem } from 'notivue' 4 | 5 | export interface NotificationsProps { 6 | item: NotivueItem 7 | icons?: NotivueIcons 8 | theme?: NotivueTheme 9 | closeAriaLabel?: string 10 | hideClose?: boolean 11 | } 12 | 13 | export type NotivueIcons = Partial< 14 | Record 15 | > 16 | 17 | export type ThemeNames = 'lightTheme' | 'pastelTheme' | 'materialTheme' | 'darkTheme' | 'slateTheme' 18 | 19 | export type NotivueTheme = Partial> 20 | 21 | type ThemeLayoutVars = 22 | | '--nv-width' 23 | | '--nv-min-width' 24 | | '--nv-spacing' 25 | | '--nv-radius' 26 | | '--nv-border-width' 27 | | '--nv-icon-size' 28 | | '--nv-title-size' 29 | | '--nv-message-size' 30 | | '--nv-shadow' 31 | | '--nv-tip-width' 32 | | '--nv-y-align' 33 | | '--nv-y-align-has-title' 34 | | '--nv-progress-height' 35 | 36 | type ThemeGlobalColorsVars = 37 | | '--nv-global-bg' 38 | | '--nv-global-fg' 39 | | '--nv-global-accent' 40 | | '--nv-global-border' 41 | 42 | type SuccessColorsVars = 43 | | '--nv-success-fg' 44 | | '--nv-success-bg' 45 | | '--nv-success-border' 46 | | '--nv-success-accent' 47 | 48 | type ErrorColorsVars = '--nv-error-fg' | '--nv-error-bg' | '--nv-error-border' | '--nv-error-accent' 49 | 50 | type WarningColorsVars = 51 | | '--nv-warning-fg' 52 | | '--nv-warning-bg' 53 | | '--nv-warning-border' 54 | | '--nv-warning-accent' 55 | 56 | type InfoColorsVars = '--nv-info-fg' | '--nv-info-bg' | '--nv-info-border' | '--nv-info-accent' 57 | 58 | type PromiseColorsVars = 59 | | '--nv-promise-fg' 60 | | '--nv-promise-bg' 61 | | '--nv-promise-border' 62 | | '--nv-promise-accent' 63 | 64 | type ThemeVars = 65 | | ThemeLayoutVars 66 | | ThemeGlobalColorsVars 67 | | SuccessColorsVars 68 | | ErrorColorsVars 69 | | WarningColorsVars 70 | | InfoColorsVars 71 | | PromiseColorsVars 72 | 73 | // New v2.4.0 aliases 74 | 75 | export type NotificationProps = NotificationsProps 76 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/AriaLive.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 35 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/Notivue.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 25 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/NotivueImpl.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 92 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useMouseEvents.ts: -------------------------------------------------------------------------------- 1 | import { computed } from 'vue' 2 | 3 | import { isMouse } from '@/Notivue/utils' 4 | import { useStore } from '@/core/useStore' 5 | 6 | export function useMouseEvents() { 7 | const { timeouts, config } = useStore() 8 | 9 | function pauseHover(e: PointerEvent) { 10 | if (isMouse(e)) timeouts.pause() 11 | } 12 | 13 | function resumeHover(e: PointerEvent) { 14 | if (isMouse(e)) timeouts.resume() 15 | } 16 | 17 | return computed(() => 18 | config.pauseOnHover.value && !timeouts.isStreamFocused.value 19 | ? { 20 | onPointerenter: pauseHover, 21 | onPointerleave: resumeHover, 22 | } 23 | : {} 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useNotivueStyles.ts: -------------------------------------------------------------------------------- 1 | import { computed, type CSSProperties } from 'vue' 2 | 3 | import { useNotivue } from '@/core/useStore' 4 | 5 | import type { NotivueElements } from 'notivue' 6 | 7 | /** 8 | * The follwing styles are not defined in a CSS file because 9 | * they are needed whether user uses default or custom components. 10 | * 11 | * Hence if users choose to only use custom components they can 12 | * remove the /notifications.css import and have no CSS at all. 13 | */ 14 | 15 | const boxSizing: CSSProperties = { boxSizing: 'border-box' } 16 | 17 | const baseStyles: Record = { 18 | list: { 19 | ...boxSizing, 20 | display: 'flex', 21 | justifyContent: 'center', 22 | listStyle: 'none', 23 | margin: '0 auto', 24 | maxWidth: 'var(--nv-root-width, 100%)', 25 | padding: '0', 26 | pointerEvents: 'none', 27 | position: 'fixed', 28 | zIndex: 'var(--nv-z, 500)', 29 | }, 30 | listItem: { 31 | ...boxSizing, 32 | display: 'flex', 33 | margin: '0', 34 | position: 'absolute', 35 | transitionProperty: 'transform', 36 | width: '100%', 37 | }, 38 | itemContainer: { 39 | ...boxSizing, 40 | maxWidth: '100%', 41 | padding: `0 0 var(--nv-gap, 0.75rem) 0`, 42 | pointerEvents: 'auto', 43 | }, 44 | } 45 | 46 | export function useNotivueStyles() { 47 | const { isTopAlign, position } = useNotivue() 48 | 49 | /** 50 | * Simulates overflow-hidden only on the opposite side of the current vertical align. 51 | * This will not clip enter animations but will contain the stream vertically. 52 | */ 53 | const offset = computed(() => { 54 | const isTop = isTopAlign.value 55 | 56 | // IMPORTANT: Order of values must match 'top right bottom left' 57 | const inset = [ 58 | `var(--nv-root-top, ${isTop ? '1.25rem' : '0px'})`, 59 | 'var(--nv-root-right, 1.25rem)', 60 | `var(--nv-root-bottom, ${isTop ? '0px' : '1.25rem'})`, 61 | 'var(--nv-root-left, 1.25rem)', 62 | ] 63 | 64 | const clipPath = inset.map((v) => `calc(-1 * ${v})`) 65 | isTop ? clipPath.splice(2, 1, '0px') : clipPath.splice(0, 1, '0px') 66 | 67 | return { inset: inset.join(' '), clipPath: `inset(${clipPath.join(' ')})` } 68 | }) 69 | 70 | const xAlignment = computed(() => ({ 71 | [isTopAlign.value ? 'top' : 'bottom']: '0', 72 | justifyContent: `var(--nv-root-x-align, ${ 73 | position.value.endsWith('left') 74 | ? 'flex-start' 75 | : position.value.endsWith('right') 76 | ? 'flex-end' 77 | : 'center' 78 | })`, 79 | })) 80 | 81 | return computed>(() => ({ 82 | list: { ...baseStyles.list, ...offset.value }, 83 | listItem: { ...baseStyles.listItem, ...xAlignment.value }, 84 | itemContainer: baseStyles.itemContainer, 85 | })) 86 | } 87 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useReducedMotion.ts: -------------------------------------------------------------------------------- 1 | import { useStore } from '@/core/useStore' 2 | import { onBeforeUnmount, onMounted } from 'vue' 3 | 4 | export function useReducedMotion() { 5 | const { animations } = useStore() 6 | 7 | const query = window.matchMedia('(prefers-reduced-motion: reduce)') 8 | 9 | const onMatch = () => animations.setReducedMotion(query.matches) 10 | 11 | onMounted(() => { 12 | onMatch() 13 | query.addEventListener?.('change', onMatch) 14 | }) 15 | 16 | onBeforeUnmount(() => { 17 | query.removeEventListener?.('change', onMatch) 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useResizeListObserver.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onBeforeUnmount, watch } from 'vue' 2 | 3 | export function useResizeListObserver(elements: HTMLElement[], onSizeChange: () => void) { 4 | let resizeObserver: ResizeObserver 5 | 6 | const calls = new WeakSet() 7 | 8 | onMounted(() => { 9 | resizeObserver = new ResizeObserver((entries, observer) => { 10 | for (const e of entries) { 11 | if (!calls.has(e.target)) { 12 | // The element is being added to the DOM, skip 13 | calls.add(e.target) 14 | } else { 15 | // The element is being removed from the DOM and its size never changed, remove 16 | if (Object.values(e.contentRect.toJSON()).every((val) => val === 0)) { 17 | calls.delete(e.target) 18 | observer.unobserve(e.target) 19 | } else { 20 | // The element actually changed size, trigger callback and remove 21 | console.log('ResizeObserver Triggered') 22 | 23 | onSizeChange() 24 | calls.delete(e.target) 25 | observer.unobserve(e.target) 26 | } 27 | } 28 | } 29 | }) 30 | }) 31 | 32 | watch( 33 | elements, 34 | (el) => { 35 | if (el.length > 0) el.forEach((el) => resizeObserver?.observe(el)) 36 | }, 37 | { flush: 'post' } 38 | ) 39 | 40 | onBeforeUnmount(() => { 41 | resizeObserver?.disconnect() 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useSizes.ts: -------------------------------------------------------------------------------- 1 | import { useWindowSize } from './useWindowSize' 2 | import { useResizeListObserver } from './useResizeListObserver' 3 | import { useStore } from '@/core/useStore' 4 | 5 | export function useSizes() { 6 | const { elements, animations } = useStore() 7 | 8 | useWindowSize(() => animations.updatePositions({ isImmediate: true })) 9 | 10 | useResizeListObserver(elements.items.value, () => animations.updatePositions()) 11 | } 12 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useTouchEvents.ts: -------------------------------------------------------------------------------- 1 | import { computed, onBeforeUnmount } from 'vue' 2 | 3 | import { useStore } from '@/core/useStore' 4 | import { isMouse } from '@/Notivue/utils' 5 | 6 | /** 7 | * The logic follows this pattern: 8 | * 9 | * Every time users tap the stream, all notifications 10 | * will pause and automatically resume after 2 seconds. 11 | * 12 | * If users keep tapping on the stream, once timeouts 13 | * are resumed, they will pause again after 2 seconds and so on. 14 | * 15 | * This is never triggered when NotivueSwipe is used as that 16 | * pointerdown event is not propagated. 17 | */ 18 | 19 | export function useTouchEvents() { 20 | const { timeouts, config } = useStore() 21 | 22 | function pauseTouch(e: PointerEvent) { 23 | if (!isMouse(e)) { 24 | timeouts.clearDebounceTimeout() 25 | timeouts.pause() 26 | 27 | timeouts.resumeWithDebounce(2000) 28 | } 29 | } 30 | 31 | onBeforeUnmount(() => { 32 | timeouts.clearDebounceTimeout() 33 | }) 34 | 35 | return computed(() => 36 | config.pauseOnTouch.value && !timeouts.isStreamFocused.value 37 | ? { onPointerdown: pauseTouch } 38 | : {} 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useWindowFocus.ts: -------------------------------------------------------------------------------- 1 | import { onMounted, onBeforeUnmount } from 'vue' 2 | 3 | import { useStore } from '@/core/useStore' 4 | 5 | export function useWindowFocus() { 6 | const { config, timeouts } = useStore() 7 | 8 | function onFocus() { 9 | if (timeouts.isStreamFocused.value) return 10 | if (config.pauseOnTabChange.value) timeouts.resume() 11 | } 12 | 13 | function onBlur() { 14 | if (timeouts.isStreamFocused.value) return 15 | if (config.pauseOnTabChange.value) timeouts.pause() 16 | } 17 | 18 | onMounted(() => { 19 | window.addEventListener('focus', onFocus) 20 | window.addEventListener('blur', onBlur) 21 | }) 22 | 23 | onBeforeUnmount(() => { 24 | window.removeEventListener('focus', onFocus) 25 | window.removeEventListener('blur', onBlur) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/composables/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { onBeforeUnmount, onMounted } from 'vue' 2 | 3 | export function useWindowSize(onResize: () => void) { 4 | function _onResize() { 5 | if (window.matchMedia('(max-width: 1100px)').matches) { 6 | onResize() 7 | } 8 | } 9 | 10 | onMounted(() => { 11 | window.addEventListener('resize', _onResize) 12 | }) 13 | 14 | onBeforeUnmount(() => { 15 | window.removeEventListener('resize', _onResize) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PROPS = { 2 | listAriaLabel: 'Notifications', 3 | } as const 4 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSProperties, Component } from 'vue' 2 | import type { ContainersTabIndexMap } from '@/NotivueKeyboard/types' 3 | import type { NotivueItem } from 'notivue' 4 | 5 | export interface NotivueProps { 6 | class?: string | Record | (string | Record)[] 7 | /** 8 | * Notification containers reactive tabindex map. Only needed if using NotivueKeyboard. 9 | * 10 | * @default undefined 11 | */ 12 | containersTabIndex?: ContainersTabIndexMap 13 | /** 14 | * Aria label for the list container. Only effective if using NotivueKeyboard. 15 | * 16 | * @default "Notifications" 17 | */ 18 | listAriaLabel?: string 19 | /** 20 | * CSS styles for the list container, list items and notification containers. 21 | * 22 | * They have higher priority over the internal styles and will override them. 23 | * 24 | * ```ts 25 | * const styles = { 26 | * list: { 27 | * position: 'relative', 28 | * height: '100%', 29 | * }, 30 | * listItem: { 31 | * // ... 32 | * }, 33 | * itemContainer: { 34 | * // ... 35 | * }, 36 | * } 37 | * ``` 38 | * 39 | * @default undefined 40 | */ 41 | styles?: Partial> 42 | } 43 | 44 | export interface NotivueComponentSlot { 45 | default(item: NotivueItem & { key?: string }): Component 46 | } 47 | 48 | // Elements 49 | 50 | export type NotivueElements = 'list' | 'listItem' | 'itemContainer' 51 | -------------------------------------------------------------------------------- /packages/notivue/Notivue/utils.ts: -------------------------------------------------------------------------------- 1 | import type { NotivueItem } from 'notivue' 2 | 3 | export const isMouse = (e: PointerEvent) => e.pointerType === 'mouse' 4 | 5 | export function getAriaLabel(item: NotivueItem) { 6 | return `${item.title ? `${item.title}: ` : ''}${item.message}` 7 | } 8 | -------------------------------------------------------------------------------- /packages/notivue/NotivueKeyboard/NotivueKeyboard.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | -------------------------------------------------------------------------------- /packages/notivue/NotivueKeyboard/constants.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey } from 'vue' 2 | import type { NotivueKeyboardData } from 'notivue' 3 | 4 | export const keyboardInjectionKey = Symbol('') as InjectionKey 5 | 6 | export const focusableEls = 7 | 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]' 8 | 9 | export const DEFAULT_PROPS = { 10 | comboKey: 'n', 11 | handleClicks: true, 12 | leaveMessage: "You're leaving the notifications stream. Press Control + N to navigate it again.", 13 | emptyMessage: 'No notifications to navigate', 14 | renderAnnouncement: true, 15 | maxAnnouncements: 2, 16 | } as const 17 | -------------------------------------------------------------------------------- /packages/notivue/NotivueKeyboard/types.ts: -------------------------------------------------------------------------------- 1 | import type { Component, ComputedRef, Ref } from 'vue' 2 | 3 | export type TabIndexValue = 0 | -1 4 | export type ContainersTabIndexMap = Record 5 | 6 | export interface NotivueKeyboardData { 7 | /** 8 | * Reactive tab index value for the custom notification focusable elements tabindex. 9 | * 10 | * Meant to be added to the custom notification elements via `:tabindex="elementsTabIndex"`. 11 | */ 12 | elementsTabIndex: Ref 13 | /** 14 | * Reactive map of tab index values for the notification containers. 15 | * 16 | * Meant to be passed as prop to Notivue via `:containersTabIndex="containersTabIndex"`. 17 | */ 18 | containersTabIndex: ComputedRef 19 | } 20 | 21 | export interface NotivueKeyboardProps { 22 | /** 23 | * Key to combine with Shift to enter or exit the stream. 24 | * 25 | * @default "n" 26 | */ 27 | comboKey?: string 28 | /** 29 | * Whether to focus next candidate or exit the stream after pressing 30 | * any button or link inside a notification. 31 | * 32 | * @default true 33 | */ 34 | handleClicks?: boolean 35 | /** 36 | * Text to be announced when leaving the stream 37 | * 38 | * @default "You're leaving the notifications stream. Press Control + N to navigate it again." 39 | */ 40 | leaveMessage?: string 41 | /** 42 | * Text to be announced when attempting to navigate the stream but no candidates are available. 43 | * 44 | * @default "No notifications to navigate" 45 | */ 46 | emptyMessage?: string 47 | /** 48 | * Whether to render the enter/leave notification or just announce it via screen reader. 49 | * 50 | * @default true 51 | */ 52 | renderAnnouncement?: boolean 53 | /** 54 | * Maximum times to announce that the user is leaving the stream. 55 | * 56 | * @default 3 57 | */ 58 | maxAnnouncements?: number 59 | } 60 | 61 | export interface NotivueKeyboardSlot { 62 | default(props: { 63 | elementsTabIndex: TabIndexValue 64 | containersTabIndex: ContainersTabIndexMap 65 | }): Component 66 | } 67 | -------------------------------------------------------------------------------- /packages/notivue/NotivueKeyboard/useKeyboardFocus.ts: -------------------------------------------------------------------------------- 1 | import { ref, onMounted, onBeforeUnmount } from 'vue' 2 | 3 | export function useKeyboardFocus() { 4 | const isKeyboardFocus = ref(false) 5 | 6 | const setKeyboardFocus = () => (isKeyboardFocus.value = true) 7 | const unsetKeyboardFocus = () => (isKeyboardFocus.value = false) 8 | 9 | const events = [ 10 | ['keydown', setKeyboardFocus], 11 | ['mousedown', unsetKeyboardFocus], 12 | ['touchstart', unsetKeyboardFocus], 13 | ] as const 14 | 15 | onMounted(() => { 16 | events.forEach(([e, handler]) => document.addEventListener(e, handler)) 17 | }) 18 | 19 | onBeforeUnmount(() => { 20 | events.forEach(([e, handler]) => document.removeEventListener(e, handler)) 21 | }) 22 | 23 | return { isKeyboardFocus } 24 | } 25 | -------------------------------------------------------------------------------- /packages/notivue/NotivueKeyboard/useLastFocused.ts: -------------------------------------------------------------------------------- 1 | import { ref, onMounted, onBeforeUnmount } from 'vue' 2 | 3 | import { useStore } from '@/core/useStore' 4 | 5 | export function useLastFocused() { 6 | const { root: stream } = useStore().elements 7 | 8 | const lastFocused = ref(null) 9 | 10 | function onFocusCapture(e: FocusEvent) { 11 | const isValidTarget = e.target instanceof HTMLElement 12 | 13 | if (isValidTarget && stream.value?.contains(e.target)) return 14 | 15 | if (isValidTarget) lastFocused.value = e.target 16 | } 17 | 18 | function focusLastElement() { 19 | console.log('Focusing last element!', lastFocused.value) 20 | 21 | if (lastFocused.value) { 22 | lastFocused.value.focus() 23 | } else { 24 | /** 25 | * This may happen once in a lifetime, For example: 26 | * 27 | * - On Safari: if users never focused an element with the keyboard 28 | * before accessing the stream as clicks do not trigger focus. 29 | * 30 | * - On Chrome/Firefox: if users never interacted with any element 31 | * before accessing the stream. 32 | * 33 | */ 34 | document.activeElement instanceof HTMLElement && document.activeElement.blur() 35 | document.body.focus() 36 | } 37 | } 38 | 39 | onMounted(() => { 40 | document.addEventListener('focus', onFocusCapture, true) 41 | }) 42 | 43 | onBeforeUnmount(() => { 44 | document.removeEventListener('focus', onFocusCapture, true) 45 | }) 46 | 47 | return { focusLastElement } 48 | } 49 | -------------------------------------------------------------------------------- /packages/notivue/NotivueKeyboard/useNotivueKeyboard.ts: -------------------------------------------------------------------------------- 1 | import { inject, ref, computed } from 'vue' 2 | 3 | import { keyboardInjectionKey } from './constants' 4 | import { isSSR } from '@/core/utils' 5 | 6 | import type { NotivueKeyboardData } from 'notivue' 7 | 8 | export function useNotivueKeyboard(): NotivueKeyboardData { 9 | if (isSSR) { 10 | return { 11 | elementsTabIndex: ref(-1), 12 | containersTabIndex: computed(() => ({})), 13 | } 14 | } 15 | 16 | return inject(keyboardInjectionKey) as NotivueKeyboardData 17 | } 18 | -------------------------------------------------------------------------------- /packages/notivue/NotivueSwipe/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_PROPS = { 2 | touchOnly: false, 3 | exclude: 'a, button', 4 | disabled: false, 5 | threshold: 0.5, 6 | } as const 7 | 8 | export const RETURN_DUR = 300 9 | 10 | export const DEBOUNCE = { 11 | Mouse: 200, 12 | Touch: 1000, 13 | TouchExternal: 1400, 14 | } 15 | -------------------------------------------------------------------------------- /packages/notivue/NotivueSwipe/types.ts: -------------------------------------------------------------------------------- 1 | import type { NotivueItem } from 'notivue' 2 | 3 | export interface NotivueSwipeProps { 4 | /** Notivue's exposed notification item. */ 5 | item: NotivueItem 6 | /** 7 | * Whether to enable clear on swipe only on touch interactions. 8 | */ 9 | touchOnly?: boolean 10 | /** 11 | * A [querySelectorAll](https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll) 12 | * string that specifies elements to be exempted from the swipe action. 13 | * 14 | * @default "a, button" 15 | */ 16 | exclude?: string 17 | /** 18 | * Whether to disable the swipe gesture or not. 19 | * Useful for disabling the behavior on desktop devices, for example. 20 | * 21 | * @default false 22 | */ 23 | disabled?: boolean 24 | /** 25 | * Fraction of notification's width needed to be swiped for clearing. 26 | * For instance, a threshold of 0.5 indicates 50% of the notification's width must be swiped. 27 | * 28 | * @default 0.5 29 | */ 30 | threshold?: number 31 | /** 32 | * @deprecated 33 | * 34 | * @since 1.4.0 35 | * 36 | * This is no longer required. 37 | * 38 | * 39 | * Whether to call the 'destroy' item method instead of 'clear' when 40 | * the swipe threshold is met. 41 | * 42 | * @default false 43 | */ 44 | destroy?: boolean 45 | } 46 | -------------------------------------------------------------------------------- /packages/notivue/astro/Notivue.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 76 | -------------------------------------------------------------------------------- /packages/notivue/astro/createNotivue.ts: -------------------------------------------------------------------------------- 1 | import { NotivueConfig } from 'notivue' 2 | 3 | import type { Plugin, App } from 'vue' 4 | 5 | export function createNotivue( 6 | pluginConfig: NotivueConfig & { 7 | startOnCreation?: boolean 8 | } = {} 9 | ): Plugin { 10 | return { 11 | install(app: App) { 12 | Object.assign(app.config.globalProperties, { 13 | notivuePluginConfig: pluginConfig, 14 | }) 15 | }, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/notivue/astro/push.ts: -------------------------------------------------------------------------------- 1 | import type { Push, PushOptions } from 'notivue' 2 | import type { PushAstroEvent, MaybeAstroPushPromiseReturn } from './types' 3 | 4 | export function pushEvent>( 5 | detail: T 6 | ): MaybeAstroPushPromiseReturn { 7 | const eventId = Date.now() 8 | 9 | // Prepare to listen for the result of the notification that will be created by NotivueAstro... 10 | let pushResult = {} as MaybeAstroPushPromiseReturn 11 | const resultEventName = `notivue:id:${eventId}` 12 | 13 | // ...upon receival, save the result and remove the listener 14 | window.addEventListener( 15 | resultEventName, 16 | ((e: CustomEvent>) => { 17 | pushResult = e.detail 18 | }) as EventListener, 19 | { once: true } 20 | ) 21 | 22 | // Dispatch the incoming push options to NotivueAstro to create the notification 23 | window.dispatchEvent( 24 | new CustomEvent('notivue:push', { 25 | detail: { 26 | ...detail, 27 | type: detail.type, 28 | resultEventName, 29 | }, 30 | }) 31 | ) 32 | 33 | // Return the result 34 | return pushResult 35 | } 36 | 37 | export const push = { 38 | success: (options: PushOptions) => pushEvent({ ...options, type: 'success' }), 39 | info: (options: PushOptions) => pushEvent({ ...options, type: 'info' }), 40 | error: (options: PushOptions) => pushEvent({ ...options, type: 'error' }), 41 | warning: (options: PushOptions) => pushEvent({ ...options, type: 'warning' }), 42 | promise: (options: PushOptions) => pushEvent({ ...options, type: 'promise' }), 43 | load: (options: PushOptions) => pushEvent({ ...options, type: 'promise' }), 44 | clearAll() { 45 | window.dispatchEvent(new CustomEvent('notivue:clear-all')) 46 | }, 47 | destroyAll() { 48 | window.dispatchEvent(new CustomEvent('notivue:destroy-all')) 49 | }, 50 | } as Push 51 | -------------------------------------------------------------------------------- /packages/notivue/astro/types.ts: -------------------------------------------------------------------------------- 1 | import type { ClearFunctions, PushPromiseReturn, NotificationType, PushOptions } from 'notivue' 2 | 3 | export type PushAstroEvent = PushOptions & { 4 | type: Exclude 5 | resultEventName: string 6 | } 7 | 8 | export type MaybeAstroPushPromiseReturn = T extends PushOptions & { type: 'promise' } 9 | ? PushPromiseReturn 10 | : T extends PushOptions 11 | ? ClearFunctions 12 | : never 13 | 14 | interface CustomEventMap { 15 | 'notivue:push': CustomEvent 16 | 'notivue:clear-all': CustomEvent 17 | 'notivue:destroy-all': CustomEvent 18 | } 19 | 20 | declare global { 21 | interface WindowEventMap extends CustomEventMap {} 22 | } 23 | -------------------------------------------------------------------------------- /packages/notivue/core/animations.css: -------------------------------------------------------------------------------- 1 | [data-notivue-align='top'] { 2 | & .Notivue__enter, 3 | & .Notivue__leave { 4 | --notivue-ty: -200%; 5 | } 6 | } 7 | 8 | [data-notivue-align='bottom'] { 9 | & .Notivue__enter, 10 | & .Notivue__leave { 11 | --notivue-ty: 200%; 12 | } 13 | } 14 | 15 | .Notivue__enter { 16 | animation: Notivue__enter-kf 350ms cubic-bezier(0.5, 1, 0.25, 1); 17 | } 18 | 19 | .Notivue__leave { 20 | animation: Notivue__leave-kf 350ms ease; 21 | } 22 | 23 | .Notivue__clearAll { 24 | animation: Notivue__clearAll-kf 500ms cubic-bezier(0.22, 1, 0.36, 1); 25 | } 26 | 27 | @keyframes Notivue__enter-kf { 28 | 0% { 29 | transform: translate3d(0, var(--notivue-ty), 0) scale(0.25); 30 | opacity: 0; 31 | } 32 | 33 | 100% { 34 | transform: translate3d(0, 0, 0) scale(1); 35 | opacity: 1; 36 | } 37 | } 38 | 39 | @keyframes Notivue__leave-kf { 40 | 0% { 41 | transform: translate3d(0, 0, 0) scale(1); 42 | opacity: 0.7; 43 | } 44 | 45 | 100% { 46 | transform: translate3d(0, var(--notivue-ty), 0) scale(0); 47 | opacity: 0; 48 | } 49 | } 50 | 51 | @keyframes Notivue__clearAll-kf { 52 | 0% { 53 | opacity: 1; 54 | } 55 | 56 | 100% { 57 | opacity: 0; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/notivue/core/constants.ts: -------------------------------------------------------------------------------- 1 | import { NotificationType as NTypeU, NotivueConfigRequired, NotificationOptions } from 'notivue' 2 | 3 | export const CLASS_PREFIX = 'Notivue__' 4 | 5 | export const DEFAULT_DURATION = 6000 6 | 7 | export const NotificationTypeKeys: Record = { 8 | SUCCESS: 'success', 9 | ERROR: 'error', 10 | WARNING: 'warning', 11 | INFO: 'info', 12 | PROMISE: 'promise', 13 | PROMISE_RESOLVE: 'promise-resolve', 14 | PROMISE_REJECT: 'promise-reject', 15 | } 16 | 17 | const success: NotificationOptions = { 18 | title: '', 19 | message: '', 20 | duration: DEFAULT_DURATION, 21 | ariaLive: 'polite', 22 | ariaRole: 'status', 23 | } 24 | 25 | const error: NotificationOptions = { 26 | ...success, 27 | ariaLive: 'assertive', 28 | ariaRole: 'alert', 29 | } 30 | 31 | const promise: NotificationOptions = { 32 | ...success, 33 | duration: Infinity, 34 | } 35 | 36 | const warning: NotificationOptions = { 37 | ...error, 38 | ariaLive: 'polite', 39 | } 40 | 41 | const info: NotificationOptions = { 42 | ...success, 43 | } 44 | 45 | export const DEFAULT_NOTIFICATION_OPTIONS = { 46 | [NotificationTypeKeys.SUCCESS]: success, 47 | [NotificationTypeKeys.ERROR]: error, 48 | [NotificationTypeKeys.WARNING]: warning, 49 | [NotificationTypeKeys.INFO]: info, 50 | [NotificationTypeKeys.PROMISE]: promise, 51 | [NotificationTypeKeys.PROMISE_RESOLVE]: success, 52 | [NotificationTypeKeys.PROMISE_REJECT]: error, 53 | } as NotivueConfigRequired['notifications'] 54 | 55 | export const DEFAULT_CONFIG: NotivueConfigRequired = { 56 | pauseOnHover: true, 57 | pauseOnTouch: true, 58 | pauseOnTabChange: true, 59 | enqueue: false, 60 | position: 'top-center', 61 | teleportTo: 'body', 62 | notifications: DEFAULT_NOTIFICATION_OPTIONS, 63 | limit: Infinity, 64 | avoidDuplicates: false, 65 | transition: 'transform 0.35s cubic-bezier(0.5, 1, 0.25, 1)', 66 | animations: { 67 | enter: CLASS_PREFIX + 'enter', 68 | leave: CLASS_PREFIX + 'leave', 69 | clearAll: CLASS_PREFIX + 'clearAll', 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /packages/notivue/core/createInstance.ts: -------------------------------------------------------------------------------- 1 | import { readonly, ref } from 'vue' 2 | 3 | import { createPushMock, setPush } from './createPush' 4 | import { createStoreWatchers } from './createStoreWatchers' 5 | 6 | import type { NotivueStore, Push } from 'notivue' 7 | 8 | export let startInstance: () => void = () => {} 9 | export let stopInstance: () => void = () => {} 10 | 11 | export function createInstance(startOnCreation: boolean) { 12 | const isRunning = ref(startOnCreation) 13 | const isRunningReadonly = readonly(isRunning) 14 | 15 | function setupInstance(store: NotivueStore, push: Push) { 16 | const watchStore = () => createStoreWatchers(store) 17 | 18 | if (startOnCreation) setPush(push) 19 | 20 | let unwatchStore = startOnCreation ? watchStore() : [() => {}] 21 | 22 | const instance = { 23 | isRunning: isRunningReadonly, 24 | startInstance() { 25 | if (isRunning.value) return 26 | 27 | setPush(push) 28 | unwatchStore = watchStore() 29 | 30 | isRunning.value = true 31 | }, 32 | stopInstance() { 33 | if (!isRunning.value) return 34 | 35 | store.items.clear() 36 | store.queue.clear() 37 | store.items.clearLifecycleEvents() 38 | 39 | setPush(createPushMock()) 40 | unwatchStore.forEach((unwatch) => unwatch()) 41 | 42 | isRunning.value = false 43 | }, 44 | } 45 | 46 | startInstance = () => instance.startInstance() 47 | stopInstance = () => instance.stopInstance() 48 | 49 | return instance 50 | } 51 | 52 | return { isRunning: isRunningReadonly, setupInstance } 53 | } 54 | -------------------------------------------------------------------------------- /packages/notivue/core/createNotivue.ts: -------------------------------------------------------------------------------- 1 | import type { App, Plugin } from 'vue' 2 | import type { NotivueConfig } from 'notivue' 3 | 4 | import { createPush } from './createPush' 5 | import { notivueInjectionKey, notivueInstanceInjectionKey } from './symbols' 6 | import { createInstance } from './createInstance' 7 | import { createPushProxies, createStore } from './createStore' 8 | 9 | export function createProvides(startOnCreation: boolean, userConfig: NotivueConfig) { 10 | const { setupInstance, isRunning } = createInstance(startOnCreation) 11 | 12 | const store = createStore(userConfig, isRunning) 13 | 14 | const proxies = createPushProxies(store) 15 | const push = Object.freeze(createPush(proxies)) 16 | 17 | const instance = setupInstance(store, push) 18 | 19 | return { 20 | store, 21 | instance, 22 | push, 23 | } 24 | } 25 | 26 | export function createNotivue( 27 | pluginConfig: NotivueConfig & { 28 | startOnCreation?: boolean 29 | } = {} 30 | ): Plugin { 31 | return { 32 | install(app: App) { 33 | const { startOnCreation = true, ...userConfig } = pluginConfig 34 | const { store, instance, push } = createProvides(startOnCreation, userConfig) 35 | 36 | app.provide(notivueInstanceInjectionKey, instance) 37 | app.provide(notivueInjectionKey, store) 38 | 39 | app.config.globalProperties.$push ||= push 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/notivue/core/createPush.ts: -------------------------------------------------------------------------------- 1 | import { unref } from 'vue' 2 | 3 | import { NotificationTypeKeys as NType } from './constants' 4 | import { createPushProxies } from './createStore' 5 | 6 | import type { NotificationType, Push, PushOptions, PushParameter } from 'notivue' 7 | 8 | export const push = createPushMock() 9 | 10 | export function setPush(p: Push) { 11 | Object.assign(push, p) 12 | } 13 | 14 | export function createPush(proxies: ReturnType): Push { 15 | let createCount = 0 16 | 17 | function push(options: PushParameter, type: NotificationType, id = `${createCount++}`) { 18 | if (typeof unref(options) === 'string') { 19 | options = { message: options } as PushOptions 20 | } 21 | 22 | proxies.push({ ...(options as PushOptions), id, type }) 23 | 24 | return { 25 | id, 26 | clear: () => proxies.clear(id), 27 | destroy: () => proxies.clear(id, { isDestroy: true }), 28 | } 29 | } 30 | 31 | return { 32 | success: (options) => push(options, NType.SUCCESS), 33 | error: (options) => push(options, NType.ERROR), 34 | warning: (options) => push(options, NType.WARNING), 35 | info: (options) => push(options, NType.INFO), 36 | promise: (options) => { 37 | const { id, clear, destroy } = push(options, NType.PROMISE) 38 | 39 | return { 40 | resolve: (options) => push(options, NType.PROMISE_RESOLVE, id), 41 | reject: (options) => push(options, NType.PROMISE_REJECT, id), 42 | success: (options) => push(options, NType.PROMISE_RESOLVE, id), 43 | error: (options) => push(options, NType.PROMISE_REJECT, id), 44 | clear, 45 | destroy, 46 | } 47 | }, 48 | load(options) { 49 | return this.promise(options) 50 | }, 51 | clearAll: () => proxies.clearAll(), 52 | destroyAll: () => proxies.destroyAll(), 53 | } 54 | } 55 | 56 | export function createPushMock(): Push { 57 | const noop = new Proxy({}, { get: () => () => {} }) as any 58 | return createPush(noop) 59 | } 60 | -------------------------------------------------------------------------------- /packages/notivue/core/createStoreWatchers.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'vue' 2 | 3 | import type { NotivueStore } from 'notivue' 4 | 5 | export function createStoreWatchers(store: NotivueStore) { 6 | return [ 7 | watch( 8 | store.items.lifecycleEventsCount, 9 | () => { 10 | store.animations.updatePositions() 11 | }, 12 | { flush: 'post' } 13 | ), 14 | 15 | watch( 16 | store.config.position, 17 | () => { 18 | store.animations.updatePositions({ isImmediate: true }) 19 | }, 20 | { flush: 'post' } 21 | ), 22 | 23 | watch( 24 | () => store.items.length === 0 && store.queue.length === 0, 25 | (isReset) => { 26 | if (isReset) { 27 | store.timeouts.reset() 28 | store.elements.setRootAttrs({}) 29 | } 30 | }, 31 | { flush: 'post' } 32 | ), 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /packages/notivue/core/symbols.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey } from 'vue' 2 | import type { NotivueInstance, NotivueStore } from 'notivue' 3 | 4 | export const notivueInstanceInjectionKey = Symbol() as InjectionKey 5 | export const notivueInjectionKey = Symbol() as InjectionKey 6 | -------------------------------------------------------------------------------- /packages/notivue/core/useStore.ts: -------------------------------------------------------------------------------- 1 | import { inject, computed, toRefs, reactive, readonly, ref } from 'vue' 2 | 3 | import { isSSR, getSlotItem } from './utils' 4 | import { notivueInjectionKey, notivueInstanceInjectionKey } from './symbols' 5 | import { push } from './createPush' 6 | import { DEFAULT_CONFIG } from './constants' 7 | 8 | import type { 9 | NotivueStore, 10 | UseNotivueReturn, 11 | NotivueComputedEntries, 12 | NotivueInstance, 13 | } from 'notivue' 14 | 15 | export function useStore() { 16 | return inject(notivueInjectionKey) as NotivueStore 17 | } 18 | 19 | /** 20 | * Composable to start and stop the Notivue instance. 21 | * 22 | * @returns 23 | * 24 | * - `startInstance` - Starts or restarts the Notivue instance. 25 | * - `stopInstance` - Stops the Notivue instance. 26 | * - `isRunning` - Readonly ref to check if the Notivue instance is running. 27 | * 28 | * @docs https://docs.notivue.smastrom.io/api/use-notivue-instance 29 | */ 30 | export function useNotivueInstance(): NotivueInstance { 31 | if (isSSR) { 32 | return { 33 | isRunning: ref(true), 34 | startInstance: () => {}, 35 | stopInstance: () => {}, 36 | } as NotivueInstance 37 | } 38 | 39 | return inject(notivueInstanceInjectionKey) as NotivueInstance 40 | } 41 | 42 | /** 43 | * Composable to get and update the current Notivue config. 44 | * 45 | * @returns 46 | * 47 | * The current [configuration](https://docs.notivue.smastrom.io/customization/configuration) 48 | * where each property is a [ref](https://vuejs.org/guide/essentials/reactivity-fundamentals.html#ref) 49 | * that allows for reactive updates and side effects watching. 50 | * 51 | * @docs https://docs.notivue.smastrom.io/api/use-notivue 52 | */ 53 | export function useNotivue(): UseNotivueReturn { 54 | if (isSSR) { 55 | return { 56 | ...toRefs(reactive(DEFAULT_CONFIG)), 57 | update: () => {}, 58 | isTopAlign: computed(() => true), 59 | isStreamPaused: ref(false), 60 | } as UseNotivueReturn 61 | } 62 | 63 | const store = useStore() 64 | 65 | return { 66 | ...store.config, 67 | isStreamPaused: readonly(store.timeouts.isStreamPaused), 68 | isTopAlign: computed(() => store.config.position.value.indexOf('top') === 0), 69 | } 70 | } 71 | 72 | /** 73 | * @deprecated 74 | * 75 | * Since version 2.0.0, import `push` directly instead. 76 | * 77 | * ```ts 78 | * import { push } from 'notivue' 79 | * ``` 80 | */ 81 | export function usePush() { 82 | return push 83 | } 84 | 85 | /** 86 | * Composable to get the current displayed notifications and queue. 87 | * 88 | * @returns 89 | * 90 | * Object of two computed properties: 91 | * 92 | * - `entries` - read-only reactive array of all the current displayed notifications 93 | * - `queue` - read-only reactive array of all the notifications waiting to be displayed 94 | * 95 | * @docs https://docs.notivue.smastrom.io/api/use-notifications 96 | */ 97 | export function useNotifications(): NotivueComputedEntries { 98 | if (isSSR) { 99 | return { 100 | entries: computed(() => []), 101 | queue: computed(() => []), 102 | } as NotivueComputedEntries 103 | } 104 | 105 | const store = useStore() 106 | 107 | return { 108 | entries: computed(() => store.items.entries.value.map(getSlotItem)), 109 | queue: computed(() => store.queue.entries.value.map(getSlotItem)), 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /packages/notivue/core/utils.ts: -------------------------------------------------------------------------------- 1 | import { toRaw, customRef, type Ref, type ToRefs } from 'vue' 2 | 3 | import { NotificationTypeKeys as NType } from './constants' 4 | 5 | import type { 6 | StoreItem, 7 | NotivueItem, 8 | HiddenInternalItemData as InternalKeys, 9 | NotificationType, 10 | NotivueConfigRequired, 11 | Obj, 12 | PushOptionsWithInternals, 13 | } from 'notivue' 14 | 15 | export const isSSR = typeof window === 'undefined' 16 | 17 | export function mergeDeep(target: T, source: Record): T { 18 | const merged: T = { ...target } 19 | 20 | for (const key in source) { 21 | if (source.hasOwnProperty(key)) { 22 | if (isPlainObject(source[key])) { 23 | merged[key as keyof T] = mergeDeep(target[key as keyof T], source[key]) as T[keyof T] 24 | } else { 25 | merged[key as keyof T] = source[key] 26 | } 27 | } 28 | } 29 | 30 | return merged 31 | } 32 | 33 | export function mergeNotificationOptions( 34 | configOptions: NotivueConfigRequired['notifications'], 35 | pushOptions: PushOptionsWithInternals 36 | ) { 37 | pushOptions.props ||= {} as T 38 | 39 | return { 40 | ...configOptions[pushOptions.type], 41 | ...configOptions.global, 42 | ...pushOptions, 43 | ...(pushOptions.type === 'promise' ? { duration: Infinity } : {}), // Enforce this 44 | } 45 | } 46 | 47 | // https://github.com/tailwindlabs/tailwindcss/blob/master/src/util/isPlainObject.js 48 | function isPlainObject(value: unknown) { 49 | if (Object.prototype.toString.call(value) !== '[object Object]') { 50 | return false 51 | } 52 | 53 | const prototype = Object.getPrototypeOf(value) 54 | return prototype === null || Object.getPrototypeOf(prototype) === null 55 | } 56 | 57 | export function createConfigRefs( 58 | target: T, 59 | source: Record, 60 | isRunning: Ref 61 | ) { 62 | const conf = mergeDeep(target, source) as T 63 | 64 | function configRef(value: T) { 65 | return customRef((track, trigger) => ({ 66 | get() { 67 | track() 68 | return value 69 | }, 70 | set(newValue) { 71 | if (!isRunning.value) return 72 | 73 | value = newValue 74 | trigger() 75 | }, 76 | })) 77 | } 78 | 79 | for (const key in conf) conf[key] = configRef(conf[key]) as any 80 | return conf as ToRefs 81 | } 82 | 83 | export function toRawConfig(config: ToRefs) { 84 | return Object.entries(config).reduce( 85 | (acc, [key, { value }]) => ({ ...acc, [key]: toRaw(value) }), 86 | {} 87 | ) as T 88 | } 89 | 90 | export const isStatic = (type: NotificationType) => 91 | type === NType.SUCCESS || type === NType.ERROR || type === NType.WARNING || type === NType.INFO 92 | 93 | export const internalKeys: (keyof InternalKeys)[] = [ 94 | 'timeout', 95 | 'resumedAt', 96 | 'remaining', 97 | // Maybe in future releases these could be exposed 98 | 'animationAttrs', 99 | 'positionStyles', 100 | ] 101 | 102 | export function getSlotItem(item: StoreItem) { 103 | return Object.fromEntries( 104 | Object.entries(item).filter(([key]) => !internalKeys.includes(key as keyof InternalKeys)) 105 | ) as NotivueItem 106 | } 107 | -------------------------------------------------------------------------------- /packages/notivue/index.ts: -------------------------------------------------------------------------------- 1 | export { push } from '@/core/createPush' 2 | export { push as pushAstro } from './astro/push' 3 | 4 | export { updateConfig } from '@/core/createStore' 5 | export { createNotivue } from '@/core/createNotivue' 6 | export { createNotivue as createNotivueAstro } from './astro/createNotivue' 7 | 8 | export { startInstance, stopInstance } from '@/core/createInstance' 9 | 10 | export { usePush, useNotivue, useNotifications, useNotivueInstance } from '@/core/useStore' 11 | export { useNotivueKeyboard } from '@/NotivueKeyboard/useNotivueKeyboard' 12 | 13 | export { default as Notivue } from '@/Notivue/Notivue.vue' 14 | export { default as NotivueAstro } from './astro/Notivue.vue' 15 | export { default as NotivueSwipe } from '@/NotivueSwipe/NotivueSwipe.vue' 16 | export { default as NotivueKeyboard } from '@/NotivueKeyboard/NotivueKeyboard.vue' 17 | 18 | export { default as Notifications } from '@/Notifications/Notification.vue' 19 | export { default as Notification } from '@/Notifications/Notification.vue' 20 | export { default as NotificationProgress } from '@/Notifications/NotificationProgress.vue' 21 | export { default as NotificationsProgress } from '@/Notifications/NotificationProgress.vue' 22 | 23 | export { 24 | lightTheme, 25 | pastelTheme, 26 | materialTheme, 27 | darkTheme, 28 | slateTheme, 29 | } from '@/Notifications/themes' 30 | 31 | export { filledIcons, outlinedIcons } from '@/Notifications/icons' 32 | 33 | export { DEFAULT_CONFIG } from '@/core/constants' 34 | 35 | export * from '@/core/types' 36 | export * from '@/Notivue/types' 37 | export * from '@/NotivueSwipe/types' 38 | export * from '@/NotivueKeyboard/types' 39 | export * from '@/Notifications/types' 40 | export * from './astro/types' 41 | -------------------------------------------------------------------------------- /packages/notivue/nuxt/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ModuleOptions } from './module' 2 | 3 | declare module '@nuxt/schema' { 4 | interface NuxtConfig { 5 | ['notivue']?: ModuleOptions 6 | } 7 | interface NuxtOptions { 8 | ['notivue']?: ModuleOptions 9 | } 10 | } 11 | 12 | declare module 'nuxt/schema' { 13 | interface NuxtConfig { 14 | ['notivue']?: ModuleOptions 15 | } 16 | interface NuxtOptions { 17 | ['notivue']?: ModuleOptions 18 | } 19 | } 20 | 21 | export { ModuleOptions, default } from './module' 22 | -------------------------------------------------------------------------------- /packages/notivue/nuxt/module.cjs: -------------------------------------------------------------------------------- 1 | module.exports = function (...args) { 2 | return import('./module.mjs').then((m) => m.default.call(this, ...args)) 3 | } 4 | 5 | const _meta = (module.exports.meta = require('./module.json')) 6 | module.exports.getMeta = () => Promise.resolve(_meta) 7 | -------------------------------------------------------------------------------- /packages/notivue/nuxt/module.d.ts: -------------------------------------------------------------------------------- 1 | import * as _nuxt_schema from '@nuxt/schema' 2 | 3 | import type { NotificationType, NotificationOptions, NotivueConfig } from 'notivue' 4 | 5 | type ModuleOptions = Omit & { 6 | startOnCreation?: boolean 7 | /** 8 | * Whether to create and inject the notivue store in the Vue app. 9 | * Equivalent of calling `createNotivue(app)` in the main.js of a non-nuxt app. 10 | */ 11 | addPlugin?: boolean 12 | /** Notification options for each type. */ 13 | notifications?: Partial< 14 | Record< 15 | NotificationType | 'global', 16 | Omit & { 17 | /** String to use as default title, an empty string doesn't render the title. */ 18 | title?: string 19 | /** String to use as default message. */ 20 | message?: string 21 | } 22 | > 23 | > 24 | } 25 | 26 | declare const _default: _nuxt_schema.NuxtModule 27 | 28 | export { type ModuleOptions, _default as default } 29 | -------------------------------------------------------------------------------- /packages/notivue/nuxt/module.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notivue/nuxt", 3 | "configKey": "notivue", 4 | "version": "2.4.5" 5 | } 6 | -------------------------------------------------------------------------------- /packages/notivue/nuxt/module.mjs: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, addPluginTemplate, addImports, addComponent } from '@nuxt/kit' 2 | import { defu } from 'defu' 3 | 4 | import { getFunctions, getObjects, getComponents } from '../shared/exports' 5 | 6 | const module = defineNuxtModule({ 7 | meta: { 8 | name: 'nuxt/notivue', 9 | configKey: 'notivue', 10 | compatibility: { 11 | nuxt: '>=3.5.0', 12 | }, 13 | }, 14 | 15 | async setup(moduleOptions, nuxt) { 16 | nuxt.options.runtimeConfig.public.notivue = defu( 17 | nuxt.options.runtimeConfig.public.notivue || {}, 18 | moduleOptions 19 | ) 20 | 21 | if (nuxt.options.runtimeConfig.public.notivue.addPlugin !== false) { 22 | addPluginTemplate({ 23 | filename: '001.notivue.client.mjs', 24 | getContents() { 25 | return ` 26 | import { createNotivue } from 'notivue' 27 | import { defineNuxtPlugin, useRuntimeConfig } from '#imports' 28 | 29 | function nullToInf(obj) { 30 | if (obj == null) return 1 / 0 31 | 32 | if (typeof obj === 'object') { 33 | for (let key in obj) obj[key] = nullToInf(obj[key]) 34 | } 35 | 36 | return obj 37 | } 38 | 39 | export default defineNuxtPlugin(({ vueApp }) => { 40 | const options = useRuntimeConfig().public?.notivue || {} 41 | const deserializedOpts = nullToInf(JSON.parse(JSON.stringify(options))) 42 | delete deserializedOpts.addPlugin 43 | 44 | const notivue = createNotivue(deserializedOpts) 45 | 46 | vueApp.use(notivue) 47 | }) 48 | ` 49 | }, 50 | }) 51 | } 52 | 53 | for (const name of [...getFunctions(), ...getObjects({ omit: ['DEFAULT_CONFIG'] })]) { 54 | addImports({ name, as: name, from: 'notivue' }) 55 | } 56 | 57 | for (const name of getComponents()) { 58 | await addComponent({ name, export: name, filePath: 'notivue' }) 59 | } 60 | }, 61 | }) 62 | 63 | export { module as default } 64 | -------------------------------------------------------------------------------- /packages/notivue/scripts/verify-exports.js: -------------------------------------------------------------------------------- 1 | import { exports } from '../shared/exports.js' 2 | import * as index from '../dist/index.js' 3 | 4 | const objExports = Object.values(exports).flat() 5 | const jsExports = Object.keys(index) 6 | 7 | if (objExports.length !== jsExports.length) { 8 | if (objExports.length < jsExports.length) { 9 | const missing = jsExports.filter((name) => !objExports.includes(name)) 10 | throw new Error('Missing exports in shared/exports.js -> ' + missing.join(', ')) 11 | } else { 12 | const missing = objExports.filter((name) => !jsExports.includes(name)) 13 | throw new Error('Missing exports in dist/index.js -> ' + missing.join(', ')) 14 | } 15 | } else { 16 | let errors = '' 17 | 18 | const jsMissing = [] 19 | const objMissing = [] 20 | 21 | objExports.forEach((name) => { 22 | if (!jsExports.includes(name)) jsMissing.push(name) 23 | }) 24 | 25 | jsExports.forEach((name) => { 26 | if (!objExports.includes(name)) objMissing.push(name) 27 | }) 28 | 29 | if (jsMissing.length > 0) { 30 | errors += 'Inconsistent exports found in shared/exports.js -> ' + jsMissing.join(', ') 31 | } 32 | 33 | if (objMissing.length > 0) { 34 | errors += '\n' + 'Inconsistent exports found in dist/index.js -> ' + objMissing.join(', ') 35 | } 36 | 37 | if (errors) throw new Error(errors) 38 | } 39 | 40 | console.log('All exports are valid.') 41 | -------------------------------------------------------------------------------- /packages/notivue/scripts/verify-tarball.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tar_file=$(find ./ -name "*.tgz" | head -n 1) 4 | temp_dir="./temp_tar_check" 5 | 6 | if [ ! -f "$tar_file" ]; then 7 | echo "Tarball not found!" 8 | exit 1 9 | fi 10 | 11 | # Create temp directory 12 | mkdir -p $temp_dir 13 | 14 | declare -a files=( 15 | "README.md" "package.json" "LICENSE" 16 | "dist/index.js" "dist/index.d.ts" "dist/astro.js" 17 | "dist/core/animations.css" "dist/Notifications/notifications.css" "dist/Notifications/notifications-progress.css" 18 | "nuxt/module.mjs" "nuxt/module.cjs" "nuxt/module.d.ts" "nuxt/module.json" 19 | "shared/exports.js" 20 | ) 21 | 22 | for file in "${files[@]}"; do 23 | if ! tar tzf "$tar_file" | grep -qE "package/$file$"; then 24 | echo "File $file not found in tarball." 25 | exit 1 26 | fi 27 | done 28 | 29 | # Extract index.js for checking 30 | tar xzf "$tar_file" -C $temp_dir package/dist/index.js 31 | 32 | # Check if console.log exists in the extracted index.js 33 | if grep "console.log" $temp_dir/package/dist/index.js; then 34 | echo "Error: console.log found in index.js" 35 | rm -rf $temp_dir 36 | exit 1 37 | fi 38 | 39 | echo "All tarball checks passed." 40 | rm -rf $temp_dir 41 | -------------------------------------------------------------------------------- /packages/notivue/shared/ClientOnly.ts: -------------------------------------------------------------------------------- 1 | import { createElementBlock, defineComponent, onMounted, ref } from 'vue' 2 | 3 | /** 4 | * This is a streamlined version of Nuxt's 5 | * which like it also relies on a boolean ref to render the slot. 6 | * 7 | * While this is unnecessary for client-only apps but definetely required 8 | * for Nuxt apps, users may still be using 'vite-plugin-ssr' or whatever 9 | * non-nuxt SSR solution. 10 | * 11 | * This universally removes any chance of SSR issues when importing 12 | * and while keeping the internal 13 | * store code clean without adding a bunch of (isSSR) checks. 14 | */ 15 | 16 | export const NotivueClientOnly = defineComponent({ 17 | setup(_, { slots, attrs }) { 18 | const isMounted = ref(false) 19 | 20 | onMounted(() => (isMounted.value = true)) 21 | 22 | return () => { 23 | if (isMounted.value) return slots.default?.() 24 | 25 | return createElementBlock('span', attrs, '') 26 | } 27 | }, 28 | }) 29 | -------------------------------------------------------------------------------- /packages/notivue/shared/exports.js: -------------------------------------------------------------------------------- 1 | export const exports = { 2 | functions: [ 3 | 'createNotivue', 4 | 'push', 5 | 'updateConfig', 6 | 'startInstance', 7 | 'stopInstance', 8 | 9 | 'usePush', 10 | 'useNotivue', 11 | 'useNotivueInstance', 12 | 'useNotifications', 13 | 'useNotivueKeyboard', 14 | ], 15 | objects: [ 16 | 'DEFAULT_CONFIG', 17 | 18 | 'lightTheme', 19 | 'pastelTheme', 20 | 'materialTheme', 21 | 'darkTheme', 22 | 'slateTheme', 23 | 24 | 'filledIcons', 25 | 'outlinedIcons', 26 | ], 27 | components: [ 28 | 'Notivue', 29 | 'NotivueSwipe', 30 | 'NotivueKeyboard', 31 | 32 | 'Notification', 33 | 'Notifications', // Alias 34 | 'NotificationProgress', 35 | 'NotificationsProgress', // Alias 36 | ], 37 | astro: ['NotivueAstro', 'pushAstro', 'createNotivueAstro'], 38 | } 39 | 40 | const getExports = (type, omit) => exports[type].filter((name) => !omit.includes(name)) 41 | 42 | export const getFunctions = ({ omit } = { omit: [] }) => getExports('functions', omit) 43 | export const getObjects = ({ omit } = { omit: [] }) => getExports('objects', omit) 44 | export const getComponents = ({ omit } = { omit: [] }) => getExports('components', omit) 45 | -------------------------------------------------------------------------------- /packages/notivue/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "ES2020", 5 | "moduleResolution": "Node", 6 | "strict": true, 7 | "resolveJsonModule": true, 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "lib": ["ES2015", "DOM"], 11 | "skipLibCheck": true, 12 | "noEmit": true, 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/core/*": ["core/*"], 16 | "@/shared/*": ["shared/*"], 17 | "@/Notivue/*": ["Notivue/*"], 18 | "@/NotivueSwipe/*": ["NotivueSwipe/*"], 19 | "@/NotivueKeyboard/*": ["NotivueKeyboard/*"], 20 | "@/Notifications/*": ["Notifications/*"], 21 | "@/astro": ["astro/*"], 22 | "notivue": ["index.ts"] 23 | } 24 | }, 25 | "include": ["."] 26 | } 27 | -------------------------------------------------------------------------------- /packages/notivue/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import { fileURLToPath } from 'url' 3 | import { writeFileSync } from 'fs' 4 | 5 | import vue from '@vitejs/plugin-vue' 6 | import dts from 'vite-plugin-dts' 7 | 8 | // @ts-ignore 9 | import { getFunctions, getObjects, getComponents } from './shared/exports' 10 | 11 | const path = (url: string) => fileURLToPath(new URL(url, import.meta.url)) 12 | 13 | const isFinalBundle = !process.argv.includes('--watch') 14 | 15 | export default defineConfig({ 16 | resolve: { 17 | alias: { 18 | '@/core': path('./core'), 19 | '@/shared': path('./shared'), 20 | '@/Notivue': path('./Notivue'), 21 | '@/NotivueSwipe': path('./NotivueSwipe'), 22 | '@/NotivueKeyboard': path('./NotivueKeyboard'), 23 | '@/Notifications': path('./Notifications'), 24 | '@/astro': path('./astro'), 25 | notivue: path('./index.ts'), 26 | }, 27 | }, 28 | esbuild: { 29 | drop: isFinalBundle ? ['console'] : [], 30 | minifyIdentifiers: false, 31 | minifySyntax: false, 32 | }, 33 | build: { 34 | emptyOutDir: isFinalBundle, 35 | target: 'es2015', 36 | minify: false, 37 | lib: { 38 | entry: 'index.ts', 39 | fileName: 'index', 40 | formats: ['es'], 41 | }, 42 | rollupOptions: { 43 | external: ['vue'], 44 | output: { 45 | globals: { 46 | vue: 'Vue', 47 | }, 48 | }, 49 | }, 50 | }, 51 | plugins: [ 52 | dts({ 53 | rollupTypes: true, 54 | }), 55 | vue(), 56 | { 57 | name: 'write-astro-entry', 58 | closeBundle() { 59 | const astroReExports = [ 60 | ...getFunctions({ omit: ['push', 'createNotivue'] }), 61 | ...getComponents({ omit: ['Notivue'] }), 62 | ...getObjects(), 63 | ] 64 | 65 | writeFileSync( 66 | 'dist/astro.js', 67 | [ 68 | 'export { pushAstro as push } from "./index.js";', 69 | 'export { NotivueAstro as Notivue } from "./index.js";', 70 | 'export { createNotivueAstro as createNotivue } from "./index.js";', 71 | ...astroReExports.map((name) => `export { ${name} } from "./index.js";`), 72 | ].join('\n') 73 | ) 74 | }, 75 | }, 76 | ], 77 | }) 78 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | node_modules 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /playground/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | 3 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 79 | 80 | 95 | -------------------------------------------------------------------------------- /playground/assets/inter-v13-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/notivue/5d5b8e85b40a3271e151b27a206872fa3c19f1c0/playground/assets/inter-v13-latin-500.woff2 -------------------------------------------------------------------------------- /playground/assets/inter-v13-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/notivue/5d5b8e85b40a3271e151b27a206872fa3c19f1c0/playground/assets/inter-v13-latin-700.woff2 -------------------------------------------------------------------------------- /playground/assets/profile-picture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/notivue/5d5b8e85b40a3271e151b27a206872fa3c19f1c0/playground/assets/profile-picture.jpg -------------------------------------------------------------------------------- /playground/assets/pt-sans-narrow-v17-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/notivue/5d5b8e85b40a3271e151b27a206872fa3c19f1c0/playground/assets/pt-sans-narrow-v17-latin-700.woff2 -------------------------------------------------------------------------------- /playground/assets/pt-sans-narrow-v17-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/notivue/5d5b8e85b40a3271e151b27a206872fa3c19f1c0/playground/assets/pt-sans-narrow-v17-latin-regular.woff2 -------------------------------------------------------------------------------- /playground/components/custom-notifications/SimpleNotification.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 42 | 43 | 97 | -------------------------------------------------------------------------------- /playground/components/icons/ArrowIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 26 | -------------------------------------------------------------------------------- /playground/components/icons/CloseIcon.vue: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /playground/components/icons/CustomIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /playground/components/icons/DestroyIcon.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /playground/components/icons/DismissIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /playground/components/icons/InfoIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /playground/components/icons/PromiseIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /playground/components/icons/SuccessIcon.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /playground/components/icons/VueIcon.vue: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /playground/components/icons/WarnIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /playground/components/nav/Nav.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 72 | 73 | 123 | -------------------------------------------------------------------------------- /playground/components/nav/NavActions.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /playground/components/nav/NavNotificationsCustomization.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | 49 | 56 | -------------------------------------------------------------------------------- /playground/components/nav/NavNotificationsThemes.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 33 | 34 | 41 | utils/store 42 | -------------------------------------------------------------------------------- /playground/components/nav/NavNotivuePosition.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 56 | 57 | 86 | -------------------------------------------------------------------------------- /playground/components/nav/NavPushBuiltIn.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 61 | -------------------------------------------------------------------------------- /playground/components/nav/NavPushHeadless.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 92 | -------------------------------------------------------------------------------- /playground/components/shared/Button.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 18 | 19 | 24 | -------------------------------------------------------------------------------- /playground/components/shared/ButtonGroup.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | 23 | 47 | -------------------------------------------------------------------------------- /playground/components/shared/QueueCount.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 27 | 28 | 86 | -------------------------------------------------------------------------------- /playground/middleware/push.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | updateConfig((currConf) => { 3 | console.log('Current config:', currConf) 4 | return {} 5 | }) 6 | 7 | push.info('Welcome to Notivue! Use the controls below to test it out.') 8 | }) 9 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { getHead } from './utils/head' 2 | 3 | export default defineNuxtConfig({ 4 | modules: ['notivue/nuxt'], 5 | ssr: true, 6 | devtools: { 7 | enabled: false, 8 | }, 9 | experimental: { 10 | componentIslands: true, 11 | }, 12 | notivue: { 13 | // addPlugin: true, 14 | // startOnCreation: true, 15 | notifications: { 16 | global: { 17 | // duration: Infinity, 18 | }, 19 | }, 20 | }, 21 | nitro: { 22 | preset: 'cloudflare-pages', 23 | }, 24 | app: { 25 | head: getHead(), 26 | }, 27 | vite: { 28 | esbuild: { 29 | minifyIdentifiers: !import.meta.env.DEV, 30 | minifySyntax: !import.meta.env.DEV, 31 | }, 32 | build: { 33 | minify: import.meta.env.DEV ? false : 'esbuild', 34 | cssMinify: 'lightningcss', 35 | }, 36 | css: { 37 | transformer: 'lightningcss', 38 | }, 39 | }, 40 | css: [ 41 | 'assets/style.css', 42 | 'notivue/notifications.css', 43 | 'notivue/notifications-progress.css', 44 | 'notivue/animations.css', 45 | ], 46 | }) 47 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notivue-playground", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxi cleanup && nuxt dev --port=5173", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "devDependencies": { 12 | "@types/luxon": "^3.4.2", 13 | "@types/node": "^22.5.1", 14 | "lightningcss": "^1.26.0", 15 | "nuxt": "^3.13.0", 16 | "vue": "^3.4.38" 17 | }, 18 | "dependencies": { 19 | "luxon": "^3.5.0", 20 | "notivue": "workspace:*" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /playground/plugins/store.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(() => { 2 | return { 3 | provide: { 4 | store: createStore(), 5 | }, 6 | } 7 | }) 8 | -------------------------------------------------------------------------------- /playground/public/icon.svg: -------------------------------------------------------------------------------- 1 | 7 | 11 | 12 | 34 | 35 | -------------------------------------------------------------------------------- /playground/public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/notivue/5d5b8e85b40a3271e151b27a206872fa3c19f1c0/playground/public/og-image.jpg -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/utils/date.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon' 2 | 3 | export function toNow(date: number) { 4 | return DateTime.fromMillis(date).toRelative({ locale: 'en', padding: 60 * 1000 }) 5 | } 6 | -------------------------------------------------------------------------------- /playground/utils/head.ts: -------------------------------------------------------------------------------- 1 | const description = 'Notivue is a powerful toast notification system for Vue and Nuxt.' 2 | 3 | export function getHead() { 4 | return { 5 | title: 'Notivue - Powerful toast notification system for Vue and Nuxt', 6 | link: [ 7 | { 8 | rel: 'icon', 9 | href: '/icon.svg', 10 | }, 11 | ], 12 | htmlAttrs: { 13 | lang: 'en', 14 | }, 15 | meta: [ 16 | { 17 | hid: 'description', 18 | name: 'description', 19 | content: description, 20 | }, 21 | { 22 | hid: 'og:title', 23 | property: 'og:title', 24 | content: 'Notivue - ' + description, 25 | }, 26 | { 27 | hid: 'og:description', 28 | property: 'og:description', 29 | content: description, 30 | }, 31 | { 32 | hid: 'og:image', 33 | property: 'og:image', 34 | content: '/og-image.jpg', 35 | }, 36 | { 37 | hid: 'og:url', 38 | property: 'og:url', 39 | content: 'https://notivue.smastrom.io', 40 | }, 41 | { 42 | hid: 'twitter:title', 43 | name: 'twitter:title', 44 | content: 'Notivue - ' + description, 45 | }, 46 | { 47 | hid: 'twitter:description', 48 | name: 'twitter:description', 49 | content: description, 50 | }, 51 | 52 | { 53 | hid: 'twitter:image', 54 | name: 'twitter:image', 55 | content: '/og-image.jpg', 56 | }, 57 | { 58 | hid: 'twitter:card', 59 | name: 'twitter:card', 60 | content: 'summary_large_image', 61 | }, 62 | ], 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /playground/utils/misc.ts: -------------------------------------------------------------------------------- 1 | export function getRandomInt(min: number, max: number) { 2 | min = Math.ceil(min) 3 | max = Math.floor(max) 4 | return Math.floor(Math.random() * (max - min) + min) 5 | } 6 | 7 | export const isSSR = typeof window === 'undefined' 8 | 9 | export function isMobile() { 10 | if (isSSR) return false 11 | return /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(navigator.userAgent) 12 | } 13 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'tests' 4 | - 'playground' 5 | - 'astro-playground' 6 | -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | pnpm-debug.log* 3 | 4 | node_modules 5 | .DS_Store 6 | dist 7 | coverage 8 | 9 | /cypress/downloads/ 10 | /cypress/screenshots/ 11 | /cypress/videos/ 12 | 13 | # Editor directories and files 14 | .vscode/* 15 | !.vscode/extensions.json 16 | .idea 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /tests/Notifications/accessibility.cy.ts: -------------------------------------------------------------------------------- 1 | import { Classes } from '@/Notifications/constants' 2 | 3 | it('All elements are accessible', () => { 4 | cy.mountNotifications({ 5 | options: { 6 | ariaRole: 'alert', 7 | ariaLive: 'assertive', 8 | }, 9 | }) 10 | 11 | .get('.Success') 12 | .click() 13 | 14 | cy.injectAxe() 15 | cy.checkA11y(`.${Classes.NOTIFICATION}`) 16 | 17 | cy.get('.Notivue__content') 18 | .should('have.attr', 'role', 'alert') 19 | .and('have.attr', 'aria-live', 'assertive') 20 | }) 21 | -------------------------------------------------------------------------------- /tests/Notifications/close-button.cy.ts: -------------------------------------------------------------------------------- 1 | import { Classes } from '@/Notifications/constants' 2 | 3 | it('Close button dismisses notification', () => { 4 | cy.mountNotifications() 5 | 6 | .get('.Success') 7 | .click() 8 | 9 | .get(`.${Classes.CLOSE}`) 10 | .click() 11 | .getNotifications() 12 | .should('have.length', 0) 13 | }) 14 | 15 | it('It is not renderer if hideClose is true', () => { 16 | cy.mountNotifications({ 17 | hideClose: true, 18 | }) 19 | 20 | .clickAllStatic() 21 | 22 | .get(`.${Classes.CLOSE}`) 23 | .should('not.exist') 24 | }) 25 | -------------------------------------------------------------------------------- /tests/Notifications/components/Notivue.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 41 | -------------------------------------------------------------------------------- /tests/Notifications/elements.cy.ts: -------------------------------------------------------------------------------- 1 | import { NotivueIcons, outlinedIcons } from 'notivue' 2 | 3 | import { Classes as _Classes } from '@/Notifications/constants' 4 | import { DEFAULT_ANIM_DURATION } from '@/support/utils' 5 | 6 | const { TRANSITION, ...Classes } = _Classes 7 | 8 | const defaultClasses = Object.values(Classes).filter((className) => className !== Classes.DUPLICATE) 9 | 10 | it('All elements are rendered and only exists one element per class', () => { 11 | cy.mountNotifications({ 12 | options: { 13 | title: 'Success', 14 | message: 'This is a success message', 15 | }, 16 | }) 17 | 18 | .get('.Success') 19 | .click() 20 | 21 | defaultClasses.forEach((className) => { 22 | cy.get(`.${className}`).should('exist').and('have.length', 1) 23 | }) 24 | }) 25 | 26 | it('Duplicate class is added correctly', () => { 27 | cy.mountNotifications(undefined, { 28 | avoidDuplicates: true, 29 | }) 30 | 31 | .get('.Success') 32 | .click() 33 | .wait(DEFAULT_ANIM_DURATION) 34 | .get('.Success') 35 | .click() 36 | 37 | cy.get(`.${Classes.DUPLICATE}`).should('exist').and('have.length', 1) 38 | }) 39 | 40 | it('Title is not rendered by default (if empty string) while all other elements are', () => { 41 | cy.mountNotifications() 42 | 43 | .get('.Success') 44 | .click() 45 | 46 | defaultClasses.forEach((className) => { 47 | if (className === Classes.TITLE) { 48 | cy.get(`.${className}`).should('not.exist') 49 | } else { 50 | cy.get(`.${className}`).should('exist').and('have.length', 1) 51 | } 52 | }) 53 | }) 54 | 55 | function getIconConfig(iconObj: NotivueIcons) { 56 | return { 57 | icons: { 58 | ...outlinedIcons, 59 | ...iconObj, 60 | }, 61 | } 62 | } 63 | 64 | describe('Icons', () => { 65 | it('Icon is not rendered if null', () => { 66 | cy.mountNotifications(getIconConfig({ success: null })) 67 | 68 | .get('.Success') 69 | .click() 70 | 71 | .get(`.${Classes.ICON}`) 72 | .should('not.exist') 73 | }) 74 | 75 | it('Text icons are rendered properly', () => { 76 | cy.mountNotifications(getIconConfig({ success: 'SOMETEXT' })) 77 | 78 | .get('.Success') 79 | .click() 80 | 81 | .get(`.${Classes.ICON}`) 82 | .should('exist') 83 | .and('have.length', 1) 84 | .and('have.text', 'SOMETEXT') 85 | .and('have.prop', 'tagName') 86 | .and('eq', 'DIV') 87 | }) 88 | }) 89 | 90 | it('Close button is not render if null', () => { 91 | cy.mountNotifications(getIconConfig({ close: null })) 92 | 93 | .get('.Success') 94 | .click() 95 | 96 | cy.get(`.${Classes.CLOSE}`).should('not.exist') 97 | cy.get(`.${Classes.NOTIFICATION}`).should('not.contain', Classes.CLOSE_ICON) 98 | }) 99 | -------------------------------------------------------------------------------- /tests/Notifications/themes.cy.ts: -------------------------------------------------------------------------------- 1 | import { lightTheme, darkTheme, pastelTheme, materialTheme, slateTheme } from 'notivue' 2 | import { Classes } from '@/Notifications/constants' 3 | 4 | describe('Themes', () => { 5 | it('All themes are injected properly', () => { 6 | ;[lightTheme, darkTheme, pastelTheme, materialTheme, slateTheme].forEach((theme) => { 7 | cy.mountAndCheckTheme(theme) 8 | }) 9 | }) 10 | 11 | it('Light theme is applied by default', () => { 12 | cy.mountNotifications() 13 | 14 | .get('.Success') 15 | .click() 16 | 17 | .get(`.${Classes.NOTIFICATION}`) 18 | .should('have.attr', 'style') 19 | 20 | .checkTheme(lightTheme) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /tests/Notivue/accessibility.cy.ts: -------------------------------------------------------------------------------- 1 | it('All elements should be accessible', () => { 2 | cy.mountNotivue().clickRandomStatic() 3 | 4 | cy.injectAxe() 5 | cy.checkA11y('.Root') 6 | }) 7 | 8 | describe('Aria label', () => { 9 | it('Message is always renderd', () => { 10 | cy.mountNotivue({ 11 | config: { 12 | notifications: { 13 | global: { message: 'This is a message' }, 14 | }, 15 | }, 16 | }) 17 | 18 | .clickRandomStatic() 19 | .getContainer() 20 | .should('have.attr', 'aria-label', 'This is a message') 21 | }) 22 | 23 | it('Title is rendered along with the message if defined', () => { 24 | cy.mountNotivue({ 25 | config: { 26 | notifications: { 27 | global: { title: 'This is a title', message: 'This is a message' }, 28 | }, 29 | }, 30 | }) 31 | 32 | .clickRandomStatic() 33 | .getContainer() 34 | .should('have.attr', 'aria-label', 'This is a title: This is a message') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /tests/Notivue/attributes.cy.ts: -------------------------------------------------------------------------------- 1 | it('Notivue attributes are added correctly', () => { 2 | cy.mountNotivue({ 3 | props: { 4 | class: 'CustomClass', 5 | }, 6 | }) 7 | 8 | .clickRandomStatic() 9 | 10 | .get('ol') 11 | .should('have.class', 'CustomClass') 12 | .invoke('attr', 'data-notivue-align') 13 | .should('exist') 14 | 15 | .get('li') 16 | .and('have.attr', 'tabindex', '-1') 17 | .invoke('attr', 'data-notivue-id') 18 | 19 | .get('li > div') 20 | .invoke('attr', 'data-notivue-container') 21 | .should('exist') 22 | .get('li > div') 23 | .invoke('attr', 'tabindex') 24 | .should('exist') 25 | }) 26 | -------------------------------------------------------------------------------- /tests/Notivue/config-animations.cy.ts: -------------------------------------------------------------------------------- 1 | import type { VueWrapper } from '@vue/test-utils' 2 | 3 | // In cypress/support/styles.css 4 | const customAnims = { 5 | enter: 'fade-in', 6 | leave: 'fade-out', 7 | clearAll: 'fade-all', 8 | } 9 | 10 | describe('Animations', () => { 11 | it('Custom animations classes are toggled properly', () => { 12 | cy.mountNotivue({ config: { animations: customAnims } }).checkAnimations( 13 | `.${customAnims.enter}`, 14 | `.${customAnims.leave}`, 15 | `.${customAnims.clearAll}` 16 | ) 17 | }) 18 | 19 | it('Custom animations are merged properly with defaults', () => { 20 | cy.mountNotivue({ 21 | config: { 22 | animations: { 23 | leave: customAnims.leave, 24 | clearAll: customAnims.clearAll, 25 | }, 26 | }, 27 | }).checkAnimations('.Notivue__enter', '.fade-out', '.fade-all') 28 | }) 29 | 30 | it('Should update animations config dynamically', () => { 31 | cy.mountNotivue() 32 | .get('@vue') 33 | .then((wrapper) => wrapper.setProps({ animations: customAnims })) 34 | 35 | .checkAnimations( 36 | `.${customAnims.enter}`, 37 | `.${customAnims.leave}`, 38 | `.${customAnims.clearAll}` 39 | ) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /tests/Notivue/config-duplicates.cy.ts: -------------------------------------------------------------------------------- 1 | import type { VueWrapper } from '@vue/test-utils' 2 | 3 | describe('Duplicates', () => { 4 | it('Should not create duplicates if static', () => { 5 | cy.mountNotivue({ config: { avoidDuplicates: true } }) 6 | 7 | for (let i = 0; i < 10; i++) { 8 | cy.clickAllStatic() 9 | } 10 | 11 | cy.getNotifications().should('have.length', 4, { timeout: 0 }) 12 | }) 13 | 14 | it('Should create duplicates if dynamic', () => { 15 | cy.mountNotivue({ config: { avoidDuplicates: true } }) 16 | 17 | for (let i = 0; i < 10; i++) { 18 | cy.get('.Promise').click() 19 | } 20 | 21 | cy.getNotifications().should('have.length', 10, { timeout: 0 }) 22 | }) 23 | 24 | it('Should replace duration correctly', () => { 25 | cy.mountNotivue({ config: { avoidDuplicates: true } }) 26 | 27 | cy.get('.Success').click() 28 | 29 | cy.wait(3000) // Remaining time 3s 30 | 31 | cy.get('.Success').click() // Remaining time 6s 32 | 33 | cy.wait(5000) // Remaining time 1s 34 | 35 | cy.getNotifications().should('have.length', 1, { timeout: 0 }) 36 | }) 37 | 38 | it('Should update config dynamically and work', () => { 39 | cy.mountNotivue({ config: { avoidDuplicates: false } }) 40 | 41 | cy.get('@vue').then((wrapper) => { 42 | wrapper.setProps({ avoidDuplicates: true }) 43 | 44 | for (let i = 0; i < 10; i++) { 45 | cy.clickAllStatic() 46 | } 47 | 48 | cy.getNotifications().should('have.length', 4, { timeout: 0 }) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /tests/Notivue/config-enqueue.cy.ts: -------------------------------------------------------------------------------- 1 | import { RESOLVE_REJECT_DELAY, getRandomInt } from '@/support/utils' 2 | 3 | import type { VueWrapper } from '@vue/test-utils' 4 | 5 | describe('Enqueue', () => { 6 | it('Should enqueue notifications according to limit', () => { 7 | const limit = getRandomInt(1, 8) 8 | 9 | cy.mountNotivue({ config: { enqueue: true, limit } }) 10 | 11 | for (let i = 0; i < 20; i++) { 12 | cy.clickRandomStatic() 13 | } 14 | 15 | cy.getNotifications().should('have.length', limit, { timeout: 0 }) 16 | }) 17 | 18 | it('Should display enqueued notifications after dismissal', () => { 19 | const limit = 3 20 | cy.mountNotivue({ config: { enqueue: true, limit } }) 21 | 22 | for (let i = 0; i < 10; i++) { 23 | cy.clickRandomStatic() 24 | 25 | if (i === 2 || i === 5 || i === 8) { 26 | cy.get('.ClearButton') 27 | .eq(0) 28 | .click() 29 | .getNotifications() 30 | .should('have.length', limit, { timeout: 0 }) 31 | } 32 | } 33 | }) 34 | 35 | it('Should update promises in the queue', () => { 36 | const limit = 1 37 | const resolvedMessage = 'Promise resolved in the queue!' 38 | 39 | cy.mountNotivue({ 40 | config: { enqueue: true, limit }, 41 | props: { 42 | newOptions: { 43 | message: 'Promise resolved in the queue!', 44 | }, 45 | }, 46 | }) 47 | .clickRandomStatic() 48 | .get('.PushPromiseAndResolve') 49 | .click() 50 | .wait(RESOLVE_REJECT_DELAY) 51 | 52 | .get('.ClearButton') 53 | .click() 54 | 55 | .getNotifications() 56 | .should('contain.text', resolvedMessage, { timeout: 0 }) 57 | }) 58 | 59 | it('Should update enqueue option dynamically', () => { 60 | const limit = getRandomInt(1, 8) 61 | 62 | cy.mountNotivue() 63 | .get('@vue') 64 | .then((wrapper) => wrapper.setProps({ enqueue: true, limit })) 65 | 66 | for (let i = 0; i < 20; i++) { 67 | cy.clickRandomStatic() 68 | } 69 | 70 | cy.getNotifications().should('have.length', limit) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/Notivue/config-limit.cy.ts: -------------------------------------------------------------------------------- 1 | import { getRandomInt } from '@/support/utils' 2 | 3 | import type { VueWrapper } from '@vue/test-utils' 4 | 5 | describe('Limit', () => { 6 | it('User-defined limit works correctly', () => { 7 | const limit = getRandomInt(1, 8) 8 | 9 | cy.mountNotivue({ config: { limit } }) 10 | 11 | for (let i = 0; i < 20; i++) { 12 | cy.clickRandomStatic() 13 | } 14 | 15 | cy.getNotifications().should('have.length', limit, { timeout: 0 }) 16 | }) 17 | 18 | it('Should update limit dynamically', () => { 19 | const newLimit = getRandomInt(1, 8) 20 | 21 | cy.mountNotivue() 22 | .get('@vue') 23 | .then((wrapper) => wrapper.setProps({ limit: newLimit })) 24 | 25 | for (let i = 0; i < 20; i++) { 26 | cy.clickRandomStatic() 27 | } 28 | 29 | cy.getNotifications().should('have.length', newLimit, { timeout: 0 }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /tests/Notivue/config-pause-on-hover.cy.ts: -------------------------------------------------------------------------------- 1 | import type { VueWrapper } from '@vue/test-utils' 2 | 3 | describe('Pause on hover', () => { 4 | beforeEach(() => { 5 | cy.throwIfDurationMismatch(6000) 6 | }) 7 | 8 | it('Can pause and resume notifications', () => { 9 | cy.mountNotivue() 10 | 11 | .clickRandomStatic() 12 | .wait(4000) // Remaining time 2000ms 13 | 14 | .get('.Notification') 15 | .realMouseMove(0, 0, { position: 'center' }) 16 | .wait(4000) // Any value greater than the remaining time 17 | 18 | .get('.Notification') 19 | .should('exist') 20 | 21 | .get('body') 22 | .realMouseMove(50, 50, { position: 'bottomRight' }) 23 | .wait(2000) 24 | 25 | .get('.Notification') 26 | .should('not.exist') 27 | }) 28 | 29 | it('Should not pause notifications if pauseOnHover is false', () => { 30 | cy.mountNotivue({ config: { pauseOnHover: false } }) 31 | 32 | .clickRandomStatic() 33 | .wait(4000) // Remaining: 2000ms 34 | 35 | .get('.Notification') 36 | .realMouseMove(0, 0, { position: 'center' }) 37 | .wait(4000) // Any value greater than the remaining time 38 | 39 | .get('.Notification') 40 | .should('not.exist') 41 | }) 42 | 43 | it('Should update config dynamically and work', () => { 44 | cy.mountNotivue({ config: { pauseOnHover: false } }) 45 | 46 | .get('@vue') 47 | .then((wrapper) => wrapper.setProps({ pauseOnHover: true })) 48 | 49 | .clickRandomStatic() 50 | .wait(4000) // Remaining time 2000ms 51 | 52 | .get('.Notification') 53 | .realMouseMove(0, 0, { position: 'center' }) 54 | .wait(4000) // Any value greater than the remaining time 55 | 56 | .get('.Notification') 57 | .should('exist') 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /tests/Notivue/config-pause-on-touch.cy.ts: -------------------------------------------------------------------------------- 1 | import type { VueWrapper } from '@vue/test-utils' 2 | 3 | describe('Pause on touch', () => { 4 | beforeEach(() => { 5 | cy.throwIfDurationMismatch(6000) 6 | }) 7 | 8 | it('Can pause and resume notifications', () => { 9 | cy.mountNotivue() 10 | 11 | .clickRandomStatic() 12 | .wait(5000) // Remaining: 1000ms 13 | 14 | .get('.Notification') 15 | .trigger('pointerdown', { pointerType: 'touch' }) 16 | .wait(2000) // More than the remaining time 17 | 18 | .get('.Notification') 19 | .should('exist') 20 | }) 21 | 22 | it('Should not pause notifications if pauseOnTouch is false', () => { 23 | cy.mountNotivue({ config: { pauseOnTouch: false } }) 24 | 25 | .clickRandomStatic() 26 | .wait(5000) // Remaining: 1000ms 27 | 28 | .get('.Notification') 29 | .trigger('pointerdown', { pointerType: 'touch' }) 30 | .wait(2000) // More than the remaining time 31 | 32 | .get('.Notification') 33 | .should('not.exist') 34 | }) 35 | 36 | it('Should update config dynamically and work', () => { 37 | cy.mountNotivue({ config: { pauseOnTouch: false } }) 38 | 39 | .get('@vue') 40 | .then((wrapper) => wrapper.setProps({ pauseOnTouch: true })) 41 | 42 | .clickRandomStatic() 43 | .wait(5000) // Remaining: 1000ms 44 | 45 | .get('.Notification') 46 | .trigger('pointerdown', { pointerType: 'touch' }) 47 | .wait(2000) // More than the remaining time 48 | 49 | .get('.Notification') 50 | .should('exist') 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /tests/Notivue/config-teleport.cy.ts: -------------------------------------------------------------------------------- 1 | import type { VueWrapper } from '@vue/test-utils' 2 | 3 | describe('Teleport', () => { 4 | it('By default is teleported to body', () => { 5 | cy.mountNotivue() 6 | 7 | .clickRandomStatic() 8 | 9 | .get('body') 10 | .children() 11 | .should('have.class', 'Root') 12 | }) 13 | 14 | it('Can teleport to different element', () => { 15 | cy.mountNotivue({ config: { teleportTo: 'html' } }) 16 | 17 | .clickRandomStatic() 18 | 19 | .get('body') 20 | .children() 21 | .should('not.have.class', 'Root') 22 | 23 | .get('html') 24 | .children() 25 | .should('have.class', 'Root') 26 | }) 27 | 28 | it('Can teleport to custom HTMLElement', () => { 29 | cy.mountNotivue({ 30 | config: { teleportTo: document.getElementById('teleport') as HTMLElement }, 31 | }) 32 | 33 | .clickRandomStatic() 34 | 35 | .get('body') 36 | .children() 37 | .should('not.have.class', 'Root') 38 | 39 | .get('#teleport') 40 | .children() 41 | .should('have.class', 'Root') 42 | }) 43 | 44 | it('Can update teleport config dynamically', () => { 45 | cy.mountNotivue() 46 | .get('@vue') 47 | .then((wrapper) => wrapper.setProps({ teleportTo: 'html' })) 48 | 49 | .clickRandomStatic() 50 | 51 | .get('body') 52 | .children() 53 | .should('not.have.class', 'Root') 54 | 55 | .get('html') 56 | .children() 57 | .should('have.class', 'Root') 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /tests/Notivue/instance.cy.ts: -------------------------------------------------------------------------------- 1 | import type { VueWrapper } from '@vue/test-utils' 2 | 3 | function testStoppedInstance() { 4 | cy.getNotifications().should('have.length', 0, { timeout: 0 }) 5 | cy.get('.QueueCount').should('have.text', '0') 6 | cy.get('.EntriesCount').should('have.text', '0') 7 | } 8 | 9 | it('Instance is stopped correctly', () => { 10 | cy.mountNotivue() 11 | cy.clickAllStatic() 12 | 13 | cy.get('.StopInstance').click() 14 | cy.clickAllStatic() 15 | 16 | testStoppedInstance() 17 | }) 18 | 19 | it('No new entries are added to the store if instance is stopped', () => { 20 | cy.mountNotivue({ 21 | config: { 22 | limit: 4, 23 | enqueue: true, 24 | }, 25 | }) 26 | 27 | for (let i = 0; i < 10; i++) { 28 | cy.clickAllStatic() 29 | } 30 | 31 | cy.get('.StopInstance').click() 32 | testStoppedInstance() 33 | }) 34 | 35 | it('Plugin can be installed without starting the instance', () => { 36 | cy.mountNotivue({ 37 | config: { 38 | startOnCreation: false, 39 | }, 40 | }) 41 | 42 | cy.clickAllStatic() 43 | testStoppedInstance() 44 | }) 45 | 46 | it('Instance can be stopped and started again', () => { 47 | cy.mountNotivue() 48 | 49 | for (let i = 0; i < 10; i++) { 50 | cy.get('.StopInstance').click() 51 | cy.clickAllStatic() 52 | testStoppedInstance() 53 | 54 | cy.get('.StartInstance') 55 | .click() 56 | .clickAllStatic() 57 | .getNotifications() 58 | .should('have.length', 4, { timeout: 0 }) 59 | .get('.EntriesCount') 60 | .should('have.text', '4', { timeout: 0 }) 61 | } 62 | }) 63 | 64 | it('Config is not updated if instance is stopped', () => { 65 | cy.mountNotivue() 66 | 67 | const differentConfig = { 68 | pauseOnHover: false, 69 | pauseOnTouch: false, 70 | pauseOnTabChange: false, 71 | enqueue: true, 72 | position: 'bottom-center', 73 | teleportTo: 'html', 74 | limit: 3, 75 | avoidDuplicates: true, 76 | } 77 | 78 | cy.get('.Config') 79 | .invoke('text') 80 | .then((initialConfig) => { 81 | cy.get('.StopInstance').click() 82 | 83 | cy.get('@vue').then((wrapper) => { 84 | wrapper.setProps(differentConfig) 85 | 86 | cy.get('.Config') 87 | .invoke('text') 88 | .should((updatedConfig) => { 89 | expect(updatedConfig).to.eq(initialConfig) 90 | }) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /tests/Notivue/prefers-reduced-motion.cy.ts: -------------------------------------------------------------------------------- 1 | describe('prefers-reduced-motion', () => { 2 | beforeEach(() => { 3 | cy.stub(window, 'matchMedia').withArgs('(prefers-reduced-motion: reduce)').returns({ 4 | matches: true, 5 | }) 6 | }) 7 | 8 | it('Should not add enter/leave animation classes', () => { 9 | cy.mountNotivue() 10 | .get('.PushAndRenderClear') 11 | .click() 12 | .get('.Notivue__enter') 13 | .should('not.exist') 14 | 15 | .get('.RenderedClear') 16 | .click() 17 | .get('.Notivue__leave') 18 | .should('not.exist') 19 | }) 20 | 21 | it('Should not add clearAll animation', () => { 22 | cy.mountNotivue() 23 | .clickRandomStatic() 24 | .get('.ClearAll') 25 | .click() 26 | .get('.Notivue__clearAll') 27 | .should('not.exist') 28 | }) 29 | 30 | it('No transition should be applied', () => { 31 | cy.mountNotivue() 32 | .clickRandomStatic() 33 | .click() 34 | .getNotifications() 35 | .should('have.css', 'transition', 'all') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tests/Notivue/push-callbacks.cy.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_DURATION } from '@/core/constants' 2 | 3 | describe('Push callbacks', () => { 4 | it('onAutoClear is only called on auto clear', () => { 5 | cy.mountNotivue() 6 | .get('.PushWithAutoClearCallback') 7 | .click() 8 | .wait(DEFAULT_DURATION) 9 | 10 | .get('.PushWithAutoClearCallback') 11 | .should('have.text', '6') 12 | 13 | .get('.PushWithManualClearCallback') 14 | .should('have.text', '0') 15 | }) 16 | 17 | it('onManualClear is only called on manual clear', () => { 18 | cy.mountNotivue() 19 | .get('.PushWithManualClearCallback') 20 | .click() 21 | 22 | .get('.PushWithAutoClearCallback') 23 | .should('have.text', '0') 24 | 25 | .get('.PushWithManualClearCallback') 26 | .should('have.text', '6') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tests/Notivue/push-methods.cy.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_DURATION } from '@/core/constants' 2 | import { DEFAULT_ANIM_DURATION, RESOLVE_REJECT_DELAY } from '@/support/utils' 3 | 4 | describe('Push', () => { 5 | it('Can push any type of notification', () => { 6 | cy.mountNotivue() 7 | .clickAll() 8 | 9 | .getNotifications() 10 | .should('have.length', 5) 11 | }) 12 | 13 | it('Dismisses any static notification', () => { 14 | cy.mountNotivue() 15 | .clickAllStatic() 16 | 17 | .getNotifications() 18 | .should('have.length', 4) 19 | 20 | .wait(DEFAULT_DURATION) 21 | 22 | .getNotifications() 23 | .should('have.length', 0) 24 | }) 25 | 26 | it('Updates and dismisses promises', () => { 27 | cy.mountNotivue() 28 | 29 | .get('.PushPromiseAndResolve') 30 | .click() 31 | .wait(RESOLVE_REJECT_DELAY) 32 | .wait(DEFAULT_DURATION) 33 | 34 | .getNotifications() 35 | .should('have.length', 0) 36 | 37 | .get('.PushPromiseAndReject') 38 | .click() 39 | .wait(RESOLVE_REJECT_DELAY) 40 | .wait(DEFAULT_DURATION) 41 | 42 | .getNotifications() 43 | .should('have.length', 0) 44 | }) 45 | 46 | it('Clears all notifications', () => { 47 | cy.mountNotivue() 48 | .clickAll() 49 | 50 | .get('.ClearAll') 51 | .click() 52 | 53 | .getNotifications() 54 | .should('have.length', 0) 55 | }) 56 | 57 | it('Destroys all notifications', () => { 58 | cy.mountNotivue() 59 | .clickAll() 60 | 61 | .get('.DestroyAll') 62 | .click() 63 | 64 | .getNotifications() 65 | .should('have.length', 0) 66 | }) 67 | 68 | it('Clears single notification', () => { 69 | cy.mountNotivue() 70 | 71 | .get('.PushAndClear') 72 | .click() 73 | .wait(RESOLVE_REJECT_DELAY) 74 | .wait(DEFAULT_ANIM_DURATION) 75 | 76 | .getNotifications() 77 | .should('have.length', 0) 78 | }) 79 | 80 | it('Destroys single notification', () => { 81 | cy.mountNotivue() 82 | 83 | .get('.PushAndDestroy') 84 | .click() 85 | .wait(RESOLVE_REJECT_DELAY) 86 | 87 | .getNotifications() 88 | .should('have.length', 0) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /tests/Notivue/push-specific-options.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Push-specific options', () => { 2 | it('Can push ariaLiveOnly notifications', () => { 3 | cy.mountNotivue() 4 | .get('.PushAriaLiveOnly') 5 | .click() 6 | 7 | .getNotifications() 8 | .should('have.length', 0, { timeout: 0 }) 9 | 10 | .get('li > div') 11 | .should('have.length', 1) 12 | .and('have.text', 'Title: Message') 13 | .invoke('attr', 'role') 14 | .should('not.be.undefined') 15 | 16 | .get('li > div') 17 | .invoke('attr', 'aria-live') 18 | .should('not.be.undefined') 19 | }) 20 | 21 | it('Can push notifications that skip the queue', () => { 22 | cy.mountNotivue({ config: { enqueue: true, limit: 1 } }) 23 | 24 | for (let i = 0; i < 10; i++) { 25 | cy.get('.PushSkipQueue').click() 26 | } 27 | 28 | cy.getNotifications().should('have.length', 10) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/Notivue/slot-callbacks.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Clear and destroy callbacks work', () => { 2 | it('Clear', () => { 3 | cy.mountNotivue() 4 | 5 | .clickRandomStatic() 6 | .get('.ClearButton') 7 | .click() 8 | 9 | .getNotifications() 10 | .should('have.length', 0) 11 | }) 12 | 13 | it('Destroy', () => { 14 | cy.mountNotivue() 15 | 16 | .clickRandomStatic() 17 | .get('.DestroyButton') 18 | .click() 19 | 20 | .getNotifications() 21 | .should('have.length', 0) 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /tests/Notivue/slot-custom-push-props.cy.ts: -------------------------------------------------------------------------------- 1 | import { RESOLVE_REJECT_DELAY, randomProps, randomProps2 } from '@/support/utils' 2 | 3 | describe('Custom props match the slot content', () => { 4 | describe('First-level notifications', () => { 5 | const componentConf = { 6 | props: { 7 | options: { props: randomProps }, 8 | }, 9 | } 10 | 11 | it('Success', () => { 12 | cy.mountNotivue(componentConf) 13 | 14 | .get('.Success') 15 | .click() 16 | .checkSlotPropsAgainst(randomProps) 17 | }) 18 | 19 | it('Error', () => { 20 | cy.mountNotivue(componentConf) 21 | 22 | .get('.Error') 23 | .click() 24 | .checkSlotPropsAgainst(randomProps) 25 | }) 26 | 27 | it('Warning', () => { 28 | cy.mountNotivue(componentConf) 29 | 30 | .get('.Warning') 31 | .click() 32 | .checkSlotPropsAgainst(randomProps) 33 | }) 34 | 35 | it('Info', () => { 36 | cy.mountNotivue(componentConf) 37 | 38 | .get('.Info') 39 | .click() 40 | .checkSlotPropsAgainst(randomProps) 41 | }) 42 | 43 | it('Promise', () => { 44 | cy.mountNotivue(componentConf) 45 | 46 | .get('.Promise') 47 | .click() 48 | .checkSlotPropsAgainst(randomProps) 49 | }) 50 | }) 51 | 52 | describe('Promise - Resolve / Reject', () => { 53 | const componentConf = { 54 | props: { 55 | options: { props: randomProps }, 56 | // Pass new options to .resolve() and .reject() 57 | newOptions: { props: randomProps2 }, 58 | }, 59 | } 60 | 61 | it('Promise - Resolve', () => { 62 | cy.mountNotivue(componentConf) 63 | 64 | .get('.PushPromiseAndResolve') 65 | .click() 66 | .wait(RESOLVE_REJECT_DELAY) // Wait for resolve 67 | .checkSlotPropsAgainst(randomProps2) 68 | }) 69 | 70 | it('Promise - Reject', () => { 71 | cy.mountNotivue(componentConf) 72 | 73 | .get('.PushPromiseAndReject') 74 | .click() 75 | .wait(RESOLVE_REJECT_DELAY) // Wait for reject 76 | .checkSlotPropsAgainst(randomProps2) 77 | }) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /tests/Notivue/slot-default-options.cy.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_NOTIFICATION_OPTIONS as DEFAULT_OPTIONS } from '@/core/constants' 2 | import { RESOLVE_REJECT_DELAY } from '@/support/utils' 3 | 4 | describe('Default options match the slot content', () => { 5 | const { success, error, warning, info, promise } = DEFAULT_OPTIONS as Record< 6 | keyof typeof DEFAULT_OPTIONS, 7 | Record 8 | > 9 | 10 | describe('First-level notifications', () => { 11 | it('Success', () => { 12 | cy.mountNotivue() 13 | 14 | .get('.Success') 15 | .click() 16 | .checkSlotAgainst(success) 17 | }) 18 | 19 | it('Error', () => { 20 | cy.mountNotivue() 21 | 22 | .get('.Error') 23 | .click() 24 | .checkSlotAgainst(error) 25 | }) 26 | 27 | it('Warning', () => { 28 | cy.mountNotivue() 29 | 30 | .get('.Warning') 31 | .click() 32 | .checkSlotAgainst(warning) 33 | }) 34 | 35 | it('Info', () => { 36 | cy.mountNotivue() 37 | 38 | .get('.Info') 39 | .click() 40 | .checkSlotAgainst(info) 41 | }) 42 | 43 | it('Promise', () => { 44 | cy.mountNotivue() 45 | 46 | .get('.Promise') 47 | .click() 48 | .checkSlotAgainst({ ...promise, duration: null }) // Infinity is not valid 49 | }) 50 | }) 51 | 52 | describe('Promise - Resolve / Reject', () => { 53 | const promiseResolve = DEFAULT_OPTIONS['promise-resolve'] as Record 54 | const promiseReject = DEFAULT_OPTIONS['promise-reject'] as Record 55 | 56 | it('Promise - Resolve', () => { 57 | cy.mountNotivue() 58 | 59 | .get('.PushPromiseAndResolve') 60 | .click() 61 | .wait(RESOLVE_REJECT_DELAY) 62 | .checkSlotAgainst(promiseResolve) 63 | }) 64 | 65 | it('Promise - Reject', () => { 66 | cy.mountNotivue() 67 | .get('.PushPromiseAndReject') 68 | .click() 69 | .wait(RESOLVE_REJECT_DELAY) 70 | .checkSlotAgainst(promiseReject) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /tests/Notivue/slot-internal-properties.cy.ts: -------------------------------------------------------------------------------- 1 | import { parseText } from '@/support/utils' 2 | import { internalKeys } from '@/core/utils' 3 | 4 | it('Hidden internal properties are never defined', () => { 5 | cy.mountNotivue() 6 | 7 | .clickRandomStatic() 8 | .getNotifications() 9 | .then((el) => expect(parseText(el)).to.not.have.keys(internalKeys)) 10 | }) 11 | 12 | it('Exposed internal properties are always defined', () => { 13 | cy.mountNotivue() 14 | 15 | .clickRandomStatic() 16 | .getNotifications() 17 | .then((el) => { 18 | expect(parseText(el)).to.include.keys(['id', 'type', 'createdAt']) 19 | 20 | expect(parseText(el).id).to.be.a('string').and.to.have.length.greaterThan(0) 21 | expect(parseText(el).type).to.be.a('string').and.to.have.length.greaterThan(0) 22 | expect(parseText(el).createdAt).to.be.a('number').and.to.be.greaterThan(0) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /tests/Notivue/transitions.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Transition styles are injected correctly', () => { 2 | it('Top alignment', () => { 3 | cy.mountNotivue() 4 | 5 | for (let i = 0; i < 20; i++) cy.clickRandomStatic() 6 | 7 | cy.get('li').then((notifications) => { 8 | let accHeights = 0 9 | 10 | notifications.each((_, notification) => { 11 | cy.checkTransitions(notification, accHeights) 12 | 13 | accHeights += notification.clientHeight 14 | }) 15 | }) 16 | }) 17 | 18 | it('Bottom alignment', () => { 19 | cy.mountNotivue({ config: { position: 'bottom-center' } }) 20 | 21 | for (let i = 0; i < 20; i++) cy.clickRandomStatic() 22 | 23 | cy.get('li').then((notifications) => { 24 | let accHeights = 0 25 | 26 | notifications.each((_, notification) => { 27 | cy.checkTransitions(notification, accHeights) 28 | 29 | accHeights += notification.clientHeight * -1 30 | }) 31 | }) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/actions.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Actions', () => { 2 | it('Should focus new candidate if pushing while focusing', () => { 3 | cy.mountKeyboard() 4 | .pushCandidate() 5 | .pushCandidate() 6 | .pushCandidate() 7 | 8 | .realPress('Tab') 9 | .realPress('Tab') 10 | .realPress('Tab') // Go last action of first container 11 | 12 | .pushCandidateSilently() // id: 3 13 | 14 | .focused() 15 | .should('have.data', 'notivueContainer', 3) 16 | }) 17 | 18 | it('Should focus next container if clicking an action', () => { 19 | cy.mountKeyboard() 20 | .pushCandidate() 21 | .pushCandidate() 22 | .pushCandidate() 23 | 24 | .realPress('Tab') 25 | .realPress('Tab') 26 | .realPress('Tab') 27 | 28 | .realPress('Tab') 29 | .realPress('Tab') // Go to action of 2nd container (id: 1) 30 | 31 | .realPress(Math.random() > 0.5 ? 'Space' : 'Enter') 32 | 33 | .focused() 34 | .should('have.data', 'notivueContainer', 0) 35 | }) 36 | 37 | it('Should leave stream if pressing Space or Enter on action in last container', () => { 38 | cy.mountKeyboard() 39 | 40 | .pushCandidate() 41 | .pushCandidate() 42 | .as('relatedTarget') 43 | 44 | .realPress('Tab') 45 | .realPress('Tab') 46 | .realPress('Tab') 47 | 48 | .realPress('Tab') 49 | .realPress('Tab') // Go to action of last container 50 | 51 | .realPress(Math.random() > 0.5 ? 'Space' : 'Enter') 52 | 53 | cy.get('@relatedTarget').should('be.focused').checkLeaveAnnouncement() 54 | }) 55 | 56 | it('Should leave stream if clicking with mouse an action in any container', () => { 57 | cy.mountKeyboard() 58 | 59 | .pushCandidate() 60 | .pushCandidate() 61 | .pushCandidate() 62 | .as('relatedTarget') 63 | 64 | .realPress('Tab') 65 | .realPress('Tab') 66 | .realPress('Tab') 67 | 68 | .realPress('Tab') 69 | .realPress('Tab') // Go to action of 2nd container 70 | 71 | .focused() 72 | .realClick() 73 | 74 | cy.get('@relatedTarget').should('be.focused').checkLeaveAnnouncement() 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/components/Candidate.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 46 | 47 | 58 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/components/Notivue.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 79 | 80 | 92 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/components/Unqualified.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 35 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/entering-stream.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Entering the stream', () => { 2 | it('Should not enter with CTRL+N if no candidates are available', () => { 3 | cy.mountKeyboard() 4 | 5 | for (let i = 0; i < 5; i++) { 6 | cy.pushUnqualified().as('relatedTarget') 7 | } 8 | 9 | cy.realPress(['ControlLeft', 'n']) 10 | 11 | cy.get('@relatedTarget') 12 | .should('be.focused') 13 | .get('.Notification') 14 | .first() 15 | .should('contain.text', 'No notifications to navigate') 16 | }) 17 | 18 | it('If a new candidate is pushed, it should be focused if tab is pressed', () => { 19 | cy.mountKeyboard().pushCandidate() 20 | 21 | cy.realPress('Tab') 22 | 23 | .focused() 24 | .should('have.data', 'notivueContainer') 25 | }) 26 | 27 | it('If an unqualified notification is pushed, it should not be focused if tab is pressed', () => { 28 | cy.mountKeyboard() 29 | .pushUnqualified() 30 | 31 | .realPress('Tab') 32 | 33 | .get('.Notification') 34 | .first() 35 | .should('not.be.focused') 36 | }) 37 | 38 | it('If multiple new candidates are pushed, the most recent should be focused if tab is pressed', () => { 39 | cy.mountKeyboard() 40 | 41 | const limit = 10 42 | 43 | for (let i = 0; i < limit; i++) { 44 | cy.pushCandidate() 45 | } 46 | 47 | cy.window() 48 | .focus() 49 | .realPress('Tab') 50 | 51 | .focused() 52 | .should('have.data', 'notivueContainer', limit - 1) 53 | }) 54 | 55 | it('If never navigated and unqualified are pushed after candidates, the first candidate should be focused once tab is pressed', () => { 56 | cy.mountKeyboard() 57 | 58 | .pushCandidate() 59 | .pushCandidate() 60 | .pushUnqualified() 61 | 62 | .realPress('Tab') 63 | 64 | .focused() 65 | .should('have.data', 'notivueContainer', 1) // Ids starts from 0 66 | }) 67 | 68 | it('Should enter with CTRL+N', () => { 69 | cy.mountKeyboard() 70 | 71 | .pushCandidate() 72 | 73 | .realPress(['ControlLeft', 'n']) 74 | 75 | .focused() 76 | .should('have.data', 'notivueContainer', 0) 77 | }) 78 | 79 | it('Should not enter with Tab if already navigated and no new candidates are available', () => { 80 | cy.mountKeyboard() 81 | 82 | for (let i = 0; i < 3; i++) { 83 | cy.pushCandidate().as('relatedTarget') 84 | } 85 | 86 | const allFocusable = 2 * 3 + 3 87 | 88 | for (let i = 0; i < allFocusable; i++) { 89 | cy.realPress('Tab') 90 | } 91 | 92 | cy.realPress('Tab') // Leave the stream 93 | .realPress('Tab') // Try to enter again 94 | 95 | .get('@relatedTarget') 96 | .should('be.focused') 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/leaving-stream.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Leaving the stream', () => { 2 | it('Should leave with CTRL+N', () => { 3 | cy.mountKeyboard() 4 | .pushCandidate() 5 | .as('relatedTarget') 6 | 7 | .realPress('Tab') 8 | 9 | .realPress(['ControlLeft', 'n']) 10 | 11 | .get('@relatedTarget') 12 | .should('be.focused') 13 | .checkLeaveAnnouncement() 14 | }) 15 | 16 | it('Should leave if pressing SHIFT+TAB on first candidate', () => { 17 | cy.mountKeyboard() 18 | .pushCandidate() 19 | .as('relatedTarget') 20 | 21 | .realPress('Tab') 22 | 23 | .realPress(['Shift', 'Tab']) 24 | 25 | .get('@relatedTarget') 26 | .should('be.focused') 27 | .checkLeaveAnnouncement() 28 | }) 29 | 30 | it('Should navigate any element and leave if pressing TAB on last candidate', () => { 31 | cy.mountKeyboard() 32 | 33 | for (let i = 0; i < 3; i++) { 34 | cy.pushCandidate().as('relatedTarget') 35 | } 36 | 37 | const allFocusable = 2 * 3 + 3 38 | 39 | for (let i = 0; i < allFocusable; i++) { 40 | cy.realPress('Tab') // Go to last focusable element 41 | } 42 | 43 | cy.realPress('Tab') // Leave the stream 44 | 45 | .get('@relatedTarget') 46 | .should('be.focused') 47 | .checkLeaveAnnouncement() 48 | }) 49 | 50 | it('Should leave with Escape', () => { 51 | cy.mountKeyboard() 52 | .pushCandidate() 53 | .as('relatedTarget') 54 | 55 | .realPress('Tab') 56 | 57 | .realPress('Escape') 58 | 59 | .get('@relatedTarget') 60 | .should('be.focused') 61 | .checkLeaveAnnouncement() 62 | }) 63 | 64 | it('Should leave if clicking outside the stream', () => { 65 | cy.mountKeyboard() 66 | .pushCandidate() 67 | .as('relatedTarget') 68 | 69 | .realPress('Tab') 70 | 71 | .get('body') 72 | .realClick({ position: 'bottomRight' }) 73 | 74 | .get('@relatedTarget') 75 | .should('be.focused') 76 | .checkLeaveAnnouncement() 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/props.cy.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_DURATION } from '@/core/constants' 2 | 3 | describe('Props', () => { 4 | it('Should apply custom leave messages', () => { 5 | cy.mountKeyboard({ 6 | leaveMessage: 'Leaving!', 7 | emptyMessage: "There's nothing here!", 8 | renderAnnouncement: true, 9 | }) 10 | .pushCandidate() 11 | 12 | .realPress('Tab') 13 | .realPress('Escape') 14 | 15 | .get('.Notification') 16 | .first() 17 | .should('contain.text', 'Leaving!') 18 | 19 | .wait(DEFAULT_DURATION) 20 | 21 | .realPress(['ControlLeft', 'N']) 22 | 23 | .get('.Notification') 24 | .first() 25 | .should('contain.text', "There's nothing here!") 26 | }) 27 | 28 | it('Should enter/exit with a custom combo key', () => { 29 | cy.mountKeyboard({ 30 | comboKey: 'u', 31 | }) 32 | .pushCandidate() 33 | .as('relatedTarget') 34 | 35 | .realPress(['ControlLeft', 'u']) 36 | 37 | .focused() 38 | .should('have.data', 'notivueContainer', 0) 39 | 40 | .realPress(['ControlLeft', 'u']) 41 | 42 | .get('@relatedTarget') 43 | .should('be.focused') 44 | }) 45 | 46 | it('Should not focus next element if `handleClicks` is false', () => { 47 | cy.mountKeyboard({ handleClicks: false }) 48 | .pushCandidate() 49 | .pushCandidate() 50 | 51 | .realPress('Tab') 52 | .realPress('Tab') 53 | .realPress('Tab') 54 | 55 | .realPress(Math.random() > 0.5 ? 'Space' : 'Enter') 56 | 57 | .get('.Candidate') 58 | .should('not.be.focused') 59 | }) 60 | 61 | it('Should not render notification if `renderAnnouncement` is false', () => { 62 | cy.mountKeyboard({ renderAnnouncement: false }) 63 | .pushCandidate() 64 | 65 | .realPress('Tab') 66 | .realPress('Escape') 67 | 68 | .get('.Notification') 69 | .should('have.length', 0) 70 | }) 71 | 72 | it('Should customize max number of leave announcements', () => { 73 | cy.mountKeyboard({ maxAnnouncements: 1 }) 74 | .pushCandidate() 75 | .pushCandidate() 76 | 77 | .realPress('Tab') 78 | .realPress('Escape') 79 | 80 | .realPress(['ControlLeft', 'N']) 81 | .realPress('Tab') 82 | .realPress('Escape') 83 | 84 | .get('.Notification') 85 | .should('contain.text', "You're leaving the notifications stream") 86 | .should('have.length', 1) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /tests/NotivueKeyboard/queue.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Queue', () => { 2 | it('Should focus new candidate from the queue once dismissed', () => { 3 | cy.mountKeyboard({ enqueue: true, limit: 1 }) 4 | 5 | .pushCandidate() 6 | .pushCandidate() 7 | 8 | .realPress('Tab') 9 | .realPress('Tab') 10 | .realPress('Space') // Dismiss first candidate (id: 0) 11 | 12 | .focused() 13 | .and('have.data', 'notivueContainer', 1) 14 | }) 15 | 16 | it('Should focus first candidate available if unqualified is pushed from the queue', () => { 17 | cy.mountKeyboard({ enqueue: true, limit: 2 }) 18 | 19 | .pushCandidate() 20 | .pushCandidate() 21 | .pushUnqualified() 22 | 23 | .realPress('Tab') 24 | .realPress('Tab') 25 | .realPress('Space') // Dismiss last candidate (id: 1) 26 | 27 | .focused() 28 | .and('have.data', 'notivueContainer', 0) 29 | }) 30 | 31 | it('Should leave the stream if unqualified is pushed and no candidates are available', () => { 32 | cy.mountKeyboard({ enqueue: true, limit: 1 }) 33 | 34 | .pushCandidate() 35 | .pushUnqualified() 36 | .as('relatedTarget') 37 | 38 | .realPress('Tab') 39 | .realPress('Tab') 40 | .realPress('Space') 41 | 42 | cy.get('@relatedTarget').should('be.focused') // We do not announce exit when last candidate is dismissed 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /tests/NotivueSwipe/clear.cy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SWIPE_NOTIFICATION_WIDTH as WIDTH, 3 | DEFAULT_ANIM_DURATION as ANIM_DUR, 4 | } from '@/support/utils' 5 | 6 | describe('Default behavior', () => { 7 | it('Should clear notification when default threshold is met', () => { 8 | cy.mountSwipe() 9 | .pushSwipeSuccess() 10 | 11 | .get('.SwipeNotification') 12 | .realSwipe('toLeft', { length: WIDTH / 2 }) 13 | 14 | .get('.SwipeNotification') 15 | .should('not.exist') 16 | }) 17 | 18 | it('Should clear notification by swiping left', () => { 19 | cy.mountSwipe().pushSwipeSuccess() 20 | 21 | cy.get('.SwipeNotification') 22 | .realSwipe('toLeft', { length: WIDTH / 2 }) 23 | 24 | .get('.SwipeNotification') 25 | .should('not.exist') 26 | }) 27 | 28 | it('Should clear notification by swiping right', () => { 29 | cy.mountSwipe().pushSwipeSuccess() 30 | 31 | cy.get('.SwipeNotification') 32 | .realSwipe('toRight', { length: WIDTH / 2 }) 33 | 34 | .get('.SwipeNotification') 35 | .should('not.exist') 36 | }) 37 | 38 | it('Should not clear if threshold is not met', () => { 39 | cy.mountSwipe() 40 | .pushSwipeSuccess() 41 | 42 | .get('.SwipeNotification') 43 | .realSwipe('toRight', { length: WIDTH * 0.3 }) 44 | 45 | .get('.SwipeNotification') 46 | .should('exist') 47 | }) 48 | 49 | it('Should not swipe if it is a promise', () => { 50 | cy.mountSwipe() 51 | .get('.Promise') 52 | .click() 53 | .wait(ANIM_DUR) 54 | 55 | .get('.SwipeNotification') 56 | .realSwipe('toRight', { length: WIDTH * 3 }) 57 | 58 | .get('.SwipeNotification') 59 | .should('exist') 60 | }) 61 | 62 | describe('Should not clear notification if swiping a button or a link', () => { 63 | it('Button', () => { 64 | cy.mountSwipe() 65 | .pushSwipeSuccess() 66 | 67 | .get('.CloseButton') 68 | .realSwipe('toRight', { length: WIDTH }) 69 | 70 | .get('.SwipeNotification') 71 | .should('exist') 72 | }) 73 | 74 | it('Link', () => { 75 | cy.mountSwipe() 76 | .pushSwipeSuccess() 77 | 78 | .get('.Link') 79 | .realSwipe('toRight', { length: WIDTH }) 80 | 81 | .get('.SwipeNotification') 82 | .should('exist') 83 | }) 84 | }) 85 | }) 86 | -------------------------------------------------------------------------------- /tests/NotivueSwipe/components/Notivue.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 51 | 52 | 65 | -------------------------------------------------------------------------------- /tests/NotivueSwipe/debounce.cy.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_ANIM_DURATION as LEAVE_ANIM_DUR, getRandomInt } from '@/support/utils' 2 | 3 | import { DEBOUNCE } from '@/NotivueSwipe/constants' 4 | import { DEFAULT_DURATION } from '@/core/constants' 5 | import { SWIPE_NOTIFICATION_WIDTH as WIDTH } from '@/support/utils' 6 | 7 | const LENGTH = 5 8 | 9 | const pointerEventOptions = { 10 | force: true, 11 | eventConstructor: 'PointerEvent', 12 | pointerType: 'touch', 13 | } 14 | 15 | describe('Debounce', () => { 16 | beforeEach(() => { 17 | cy.viewport('iphone-x') 18 | }) 19 | 20 | /** 21 | * In order for those tests to be accurate, we need a way to track how much time Cypress 22 | * takes to swipe the element and to trigger pointer events. 23 | * 24 | * We can use Cypress retry-ability mixed with cy.then to track it. 25 | * In the first test, this matches the moment the element is removed from the DOM. 26 | * 27 | * We also add the leave animation duration to the wait time, to be even more accurate. 28 | */ 29 | 30 | it('Resume is delayed after clearing', () => { 31 | cy.mountSwipe() 32 | 33 | let elapsed = Date.now() 34 | 35 | for (let i = 0; i < LENGTH; i++) { 36 | cy.get('.Success').click() 37 | } 38 | 39 | cy.get('.SwipeNotification').eq(3).realSwipe('toLeft', { length: WIDTH }) 40 | 41 | cy.get('.SwipeNotification') 42 | .should('have.length', LENGTH - 1) 43 | .then(() => { 44 | elapsed = Date.now() - elapsed 45 | console.log('Elapsed: ', elapsed) 46 | 47 | cy.wait(DEFAULT_DURATION - elapsed + DEBOUNCE.Touch + LEAVE_ANIM_DUR) 48 | cy.get('.SwipeNotification').should('have.length', LENGTH - 1) 49 | }) 50 | }) 51 | 52 | it('Resume is delayed after tapping an excluded element', () => { 53 | const child = getRandomInt(0, LENGTH - 1) 54 | 55 | cy.mountSwipe() 56 | 57 | let elapsed = Date.now() 58 | 59 | for (let i = 0; i < LENGTH; i++) { 60 | cy.get('.Success').click() 61 | } 62 | 63 | cy.get('.CloseButton') 64 | .eq(child) 65 | .trigger('pointerdown', pointerEventOptions) 66 | .then(() => { 67 | elapsed = Date.now() - elapsed 68 | console.log('Elapsed: ', elapsed) 69 | 70 | cy.wait(DEFAULT_DURATION - elapsed + DEBOUNCE.TouchExternal + LEAVE_ANIM_DUR) 71 | cy.get('.SwipeNotification').should('have.length', LENGTH - 1) 72 | }) 73 | }) 74 | 75 | it('Resume is delayed after tappping a notification', () => { 76 | const child = getRandomInt(0, LENGTH - 1) 77 | 78 | cy.mountSwipe() 79 | 80 | let elapsed = Date.now() 81 | 82 | for (let i = 0; i < LENGTH; i++) { 83 | cy.get('.Success').click() 84 | } 85 | 86 | cy.get('.SwipeNotification') 87 | .eq(child) 88 | .trigger('pointerdown', pointerEventOptions) 89 | .then(() => { 90 | cy.get('.SwipeNotification') 91 | .eq(child) 92 | .trigger('pointerup', pointerEventOptions) 93 | .then(() => { 94 | elapsed = Date.now() - elapsed 95 | console.log('Elapsed: ', elapsed) 96 | 97 | cy.wait(DEFAULT_DURATION - elapsed + DEBOUNCE.Touch + LEAVE_ANIM_DUR) 98 | cy.get('.SwipeNotification').should('have.length', LENGTH) 99 | }) 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /tests/NotivueSwipe/props.cy.ts: -------------------------------------------------------------------------------- 1 | import { SWIPE_NOTIFICATION_WIDTH as WIDTH } from '@/support/utils' 2 | 3 | /** 4 | * Don't know why this test hangs the whole github action 5 | * even if it works and succeeds locally. 6 | * 7 | * Skipping it for now. 8 | * 9 | */ 10 | if (!Cypress.env('CYPRESS_CI')) { 11 | describe('Props', () => { 12 | it('Should not swipe if disabled', () => { 13 | cy.mountSwipe({ disabled: true }) 14 | .pushSwipeSuccess() 15 | 16 | .get('.SwipeNotification') 17 | .realSwipe('toRight', { length: WIDTH }) 18 | .should('exist') 19 | }) 20 | 21 | it('Should clear with a lower threshold', () => { 22 | cy.mountSwipe({ threshold: 0.3 }) 23 | .pushSwipeSuccess() 24 | 25 | .get('.SwipeNotification') 26 | .realSwipe('toRight', { length: WIDTH * 0.3 }) 27 | 28 | .get('.SwipeNotification') 29 | .should('not.exist') 30 | }) 31 | 32 | it('Should swipe if swiping excluded element', () => { 33 | cy.mountSwipe({ exclude: '.CloseButton' }) 34 | .pushSwipeSuccess() 35 | 36 | .get('.CloseButton') 37 | .realSwipe('toRight', { length: WIDTH }) 38 | 39 | .get('.SwipeNotification') 40 | .should('exist') 41 | }) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /tests/NotivueSwipe/styles.cy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_ANIM_DURATION as ANIM_DUR, 3 | SWIPE_NOTIFICATION_WIDTH as WIDTH, 4 | } from '@/support/utils' 5 | 6 | describe('Styles', () => { 7 | it('Should have no transforms applied once returned to initial position', () => { 8 | cy.mountSwipe() 9 | .pushSwipeSuccess() 10 | 11 | .get('.SwipeNotification') 12 | .realSwipe('toRight', { length: WIDTH * 0.2 }) 13 | 14 | .get('.SwipeNotification') 15 | .invoke('css', 'transform') 16 | .should('be.eq', 'none') 17 | }) 18 | 19 | it('Should force `user-select: none;` if swipeable', () => { 20 | cy.mountSwipe() 21 | .pushSwipeSuccess() 22 | 23 | .get('.SwipeNotification') 24 | .children() 25 | .should('have.css', 'user-select', 'none') 26 | }) 27 | 28 | it('Should not force `user-select: none;` if not swipeable', () => { 29 | cy.mountSwipe() 30 | .get('.Promise') 31 | .click() 32 | .wait(ANIM_DUR) 33 | 34 | .get('.SwipeNotification') 35 | .should('have.css', 'user-select', 'auto') 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tests/NotivueSwipe/timeouts.cy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SWIPE_NOTIFICATION_WIDTH as WIDTH, 3 | DEFAULT_ANIM_DURATION as ANIM_DUR, 4 | getRandomInt, 5 | } from '@/support/utils' 6 | 7 | import { DEFAULT_DURATION } from '@/core/constants' 8 | 9 | const REPEAT = 5 10 | 11 | describe('Leave timeouts', () => { 12 | it('Should resume timeouts once cleared a notification', () => { 13 | const child = getRandomInt(0, REPEAT - 1) 14 | 15 | cy.mountSwipe() 16 | 17 | for (let i = 0; i < REPEAT; i++) { 18 | cy.get('.Success').click() 19 | } 20 | 21 | cy.wait(ANIM_DUR) 22 | 23 | cy.get('.SwipeNotification').eq(child).realSwipe('toRight', { length: WIDTH }) 24 | 25 | cy.wait(DEFAULT_DURATION) 26 | 27 | cy.get('.SwipeNotification').should('have.length', 0) 28 | }) 29 | 30 | it('Should not resume timeouts if tapping back on a notification after clearing', () => { 31 | const child = getRandomInt(0, REPEAT - 1) 32 | 33 | cy.mountSwipe() 34 | 35 | for (let i = 0; i < REPEAT; i++) { 36 | cy.get('.Success').click() 37 | } 38 | 39 | cy.wait(ANIM_DUR) 40 | 41 | cy.get('.SwipeNotification') 42 | .eq(child) 43 | .realSwipe('toRight', { length: WIDTH / 2 }) 44 | 45 | cy.get('.SwipeNotification') 46 | .eq(0) 47 | .trigger('pointerdown', { 48 | force: true, 49 | eventConstructor: 'PointerEvent', 50 | pointerType: 'touch', 51 | }) 52 | .wait(DEFAULT_DURATION * 2) // Hold for very long time 53 | 54 | cy.get('.SwipeNotification').should('have.length', REPEAT - 1) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /tests/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | import { alias } from './shared-config' 3 | 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | export default defineConfig({ 7 | video: false, 8 | viewportWidth: 1280, 9 | viewportHeight: 720, 10 | experimentalMemoryManagement: true, 11 | 12 | component: { 13 | devServer: { 14 | framework: 'vue', 15 | bundler: 'vite', 16 | viteConfig: { 17 | server: { 18 | port: 5176, 19 | }, 20 | resolve: { 21 | alias, 22 | }, 23 | plugins: [vue()], 24 | }, 25 | }, 26 | setupNodeEvents(on) { 27 | on('task', { 28 | log(message) { 29 | console.log(message) 30 | return null 31 | }, 32 | }) 33 | }, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /tests/cypress/support/commands-keyboard.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/vue' 2 | 3 | import Notivue, { CyNotivueKeyboardProps } from '@/tests/NotivueKeyboard/components/Notivue.vue' 4 | 5 | import { DEFAULT_ANIM_DURATION as ANIM_DUR } from '@/support/utils' 6 | import { createNotivue } from 'notivue' 7 | 8 | declare global { 9 | namespace Cypress { 10 | interface Chainable { 11 | mountKeyboard(props?: CyNotivueKeyboardProps): Chainable 12 | pushCandidate(): Chainable 13 | pushUnqualified(): Chainable 14 | pushCandidateSilently(): Chainable 15 | checkLeaveAnnouncement(): Chainable 16 | } 17 | } 18 | } 19 | 20 | Cypress.Commands.add('mountKeyboard', (props = {} as CyNotivueKeyboardProps) => { 21 | const notivue = createNotivue() 22 | 23 | return mount(Notivue, { 24 | global: { 25 | plugins: [notivue], 26 | }, 27 | props, 28 | }) 29 | }) 30 | 31 | Cypress.Commands.add('pushCandidate', () => cy.get('.PushCandidate').click().wait(ANIM_DUR)) 32 | 33 | Cypress.Commands.add('pushUnqualified', () => cy.get('.PushUnqualified').click().wait(ANIM_DUR)) 34 | 35 | Cypress.Commands.add('pushCandidateSilently', () => cy.get('body').type('{shift}c')) 36 | 37 | Cypress.Commands.add('checkLeaveAnnouncement', () => 38 | cy.get('.Notification').first().should('contain.text', "You're leaving the notifications stream") 39 | ) 40 | -------------------------------------------------------------------------------- /tests/cypress/support/commands-notifications.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/vue' 2 | 3 | import { Classes } from '@/Notifications/constants' 4 | 5 | import Notivue, { CyNotificationsProps } from '@/tests/Notifications/components/Notivue.vue' 6 | 7 | import { createNotivue, type NotivueTheme, type NotivueConfig } from 'notivue' 8 | 9 | declare global { 10 | namespace Cypress { 11 | interface Chainable { 12 | mountNotifications(props?: CyNotificationsProps, config?: NotivueConfig): Chainable 13 | checkTheme(theme: NotivueTheme): Chainable 14 | mountAndCheckTheme(theme: NotivueTheme): Chainable 15 | } 16 | } 17 | } 18 | 19 | Cypress.Commands.add('checkTheme', (theme: NotivueTheme) => { 20 | Object.entries(theme).forEach(([key, value]) => { 21 | cy.get(`.${Classes.NOTIFICATION}`) 22 | .should('have.attr', 'style') 23 | .and('contain', `${key}: ${value}`) 24 | }) 25 | }) 26 | 27 | Cypress.Commands.add('mountNotifications', (props = {}, config = {}) => { 28 | const notivue = createNotivue(config) 29 | 30 | return mount(Notivue, { 31 | global: { 32 | plugins: [notivue], 33 | }, 34 | props, 35 | }) 36 | }) 37 | 38 | Cypress.Commands.add('mountAndCheckTheme', (theme: NotivueTheme) => { 39 | cy.mountNotifications({ 40 | theme: theme, 41 | }) 42 | 43 | .get('.Success') 44 | .click() 45 | .checkTheme(theme) 46 | }) 47 | -------------------------------------------------------------------------------- /tests/cypress/support/commands-swipe.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/vue' 2 | 3 | import Notivue, { type CyNotivueSwipeProps } from '@/tests/NotivueSwipe/components/Notivue.vue' 4 | 5 | import { DEFAULT_ANIM_DURATION as ANIM_DUR } from '@/support/utils' 6 | import { createNotivue } from 'notivue' 7 | 8 | declare global { 9 | namespace Cypress { 10 | interface Chainable { 11 | mountSwipe(props?: CyNotivueSwipeProps): Chainable 12 | pushSwipeSuccess(): Chainable 13 | } 14 | } 15 | } 16 | 17 | Cypress.Commands.add('mountSwipe', (props = {}) => { 18 | const notivue = createNotivue() 19 | 20 | return mount(Notivue, { 21 | global: { 22 | plugins: [notivue], 23 | }, 24 | props, 25 | }) 26 | }) 27 | 28 | Cypress.Commands.add('pushSwipeSuccess', () => cy.get('.Success').click().wait(ANIM_DUR)) 29 | -------------------------------------------------------------------------------- /tests/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Notivue Component Testing 8 | 9 | 10 |
11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /tests/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import 'cypress-axe' 4 | import 'cypress-real-events' 5 | 6 | import './commands-notivue' 7 | import './commands-notifications' 8 | import './commands-swipe' 9 | import './commands-keyboard' 10 | 11 | import 'notivue/animations.css' 12 | import 'notivue/notifications.css' 13 | 14 | import './styles.css' 15 | -------------------------------------------------------------------------------- /tests/cypress/support/styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --easing: cubic-bezier(0.22, 1, 0.36, 1); 3 | } 4 | 5 | .fade-in { 6 | animation: fade-kf 300ms var(--easing); 7 | } 8 | 9 | .fade-out { 10 | animation: fade-kf 300ms var(--easing); 11 | } 12 | 13 | .fade-all { 14 | animation: fade-kf 600ms var(--easing) forwards; 15 | } 16 | 17 | @keyframes fade-kf { 18 | 0% { 19 | opacity: 1; 20 | } 21 | 100% { 22 | opacity: 0; 23 | } 24 | } 25 | 26 | .fade-in-test-prop { 27 | animation: fade-kf 800ms ease-in-out; 28 | } 29 | 30 | .fade-in-test-prop { 31 | animation: fade-kf 800ms ease-in-out; 32 | } 33 | -------------------------------------------------------------------------------- /tests/cypress/support/utils.ts: -------------------------------------------------------------------------------- 1 | import type { PushOptions } from 'notivue' 2 | 3 | export function parseText(subject: any) { 4 | return JSON.parse(subject.text()) as Record 5 | } 6 | 7 | export const RESOLVE_REJECT_DELAY = 2000 8 | 9 | /** 10 | * Delay called before clearing, destroying or updating 11 | * some Notivue tests notifications. 12 | * 13 | * Needed just to see the change in the UI 14 | */ 15 | 16 | export const SWIPE_NOTIFICATION_WIDTH = 300 17 | 18 | export const DEFAULT_ANIM_DURATION = 350 19 | 20 | export function getRandomInt(min: number, max: number) { 21 | return Math.floor(Math.random() * (max - min + 1)) + min 22 | } 23 | 24 | function randomId() { 25 | return Math.random().toString(36).substr(2, 9) 26 | } 27 | 28 | export function getRandomOptions() { 29 | return { 30 | title: randomId(), 31 | message: randomId(), 32 | duration: Math.floor(Math.random() * 10000), 33 | ariaLive: randomId(), 34 | ariaRole: randomId(), 35 | } as PushOptions 36 | } 37 | 38 | export const randomProps = { 39 | name: 'John', 40 | age: 30, 41 | married: true, 42 | address: { 43 | street: '123 Main St', 44 | city: 'New York', 45 | country: 'USA', 46 | }, 47 | job: 'Software Engineer', 48 | salary: 5000, 49 | skills: { 50 | frontend: 'JavaScript', 51 | backend: 'Node.js', 52 | database: 'MongoDB', 53 | }, 54 | } 55 | 56 | export const randomProps2 = { 57 | projects: 8, 58 | education: "Bachelor's Degree", 59 | experience: { 60 | years: 7, 61 | previousCompany: 'ABC Inc', 62 | position: 'Senior Developer', 63 | }, 64 | languages: ['English', 'Spanish'], 65 | hobbies: { 66 | hobby1: 'Reading', 67 | hobby2: 'Cooking', 68 | hobby3: 'Playing Guitar', 69 | }, 70 | favoriteNumber: 13, 71 | favoriteColor: 'Blue', 72 | hasPets: true, 73 | petNames: ['Max', 'Bella'], 74 | friends: { 75 | friend1: 'Sarah', 76 | friend2: 'Michael', 77 | friend3: 'Emily', 78 | }, 79 | isStudent: false, 80 | school: 'University of XYZ', 81 | } 82 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notivue-tests", 3 | "private": true, 4 | "scripts": { 5 | "test": "cypress run --component --browser chrome", 6 | "test:gui": "cypress open --component --browser chrome", 7 | "test:unit": "npx vitest run" 8 | }, 9 | "dependencies": { 10 | "notivue": "workspace:*" 11 | }, 12 | "devDependencies": { 13 | "@types/node": "^22.5.1", 14 | "@vitejs/plugin-vue": "^5.1.3", 15 | "@vue/test-utils": "^2.4.6", 16 | "axe-core": "^4.10.0", 17 | "cypress": "^13.14.1", 18 | "cypress-axe": "^1.5.0", 19 | "cypress-real-events": "^1.13.0", 20 | "typescript": "^5.5.4", 21 | "vite": "^5.4.2", 22 | "vitest": "^2.0.5", 23 | "vue": "^3.4.38", 24 | "vue-tsc": "^2.1.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/shared-config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | 3 | export const alias = { 4 | '@/support': resolve(__dirname, './cypress/support'), 5 | '@/tests': resolve(__dirname, './'), 6 | '@/core': resolve(__dirname, '../packages/notivue/core'), 7 | '@/Notivue': resolve(__dirname, '../packages/notivue/Notivue'), 8 | '@/NotivueSwipe': resolve(__dirname, '../packages/notivue/NotivueSwipe'), 9 | '@/Notifications': resolve(__dirname, '../packages/notivue/Notifications'), 10 | } 11 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "strict": true, 7 | "resolveJsonModule": true, 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "lib": ["ESNext", "DOM"], 11 | "checkJs": true, 12 | "skipLibCheck": true, 13 | "noEmit": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "@/support/*": ["./cypress/support/*"], 17 | "@/tests/*": ["./*"], 18 | "@/core/*": ["../packages/notivue/core/*"], 19 | "@/Notivue/*": ["../packages/notivue/Notivue/*"], 20 | "@/NotivueSwipe/*": ["../packages/notivue/NotivueSwipe/*"], 21 | "@/NotivueKeyboard/*": ["../packages/notivue/NotivueKeyboard/*"], 22 | "@/Notifications/*": ["../packages/notivue/Notifications/*"] 23 | } 24 | }, 25 | "include": ["."] 26 | } 27 | -------------------------------------------------------------------------------- /tests/vite.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from 'vite' 4 | import { alias } from './shared-config' 5 | 6 | export default defineConfig({ 7 | test: { 8 | include: ['config/*.test.ts'], 9 | }, 10 | resolve: { alias }, 11 | }) 12 | --------------------------------------------------------------------------------