├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── LICENSE ├── README.md ├── package.json ├── packages └── vue-global-loader │ ├── .gitignore │ ├── api-extractor.json │ ├── build.js │ ├── components │ ├── GlobalLoader.vue │ ├── GlobalLoaderClientOnly.js │ ├── GlobalLoaderImpl.vue │ ├── index.d.ts │ └── spinners │ │ ├── BarsSpinner.vue │ │ ├── CircleSpinner.vue │ │ ├── DotsSpinner.vue │ │ ├── PulseSpinner.vue │ │ ├── RingBarsSpinner.vue │ │ ├── RingDotSpinner.vue │ │ ├── RingSpinner.vue │ │ ├── WaveSpinner.vue │ │ └── index.d.ts │ ├── core │ ├── constants.ts │ ├── plugin.ts │ ├── store.ts │ ├── types.ts │ └── utils.ts │ ├── index.ts │ ├── nuxt │ ├── index.d.ts │ ├── module.cjs │ ├── module.d.ts │ ├── module.json │ └── module.mjs │ ├── package.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── watch.js ├── playground ├── .gitignore ├── .npmrc ├── app.vue ├── assets │ ├── global.css │ ├── golos-text-v4-latin-700.woff2 │ └── golos-text-v4-latin-regular.woff2 ├── components │ ├── ChevronIcon.vue │ ├── InstallationCommands.vue │ ├── SpinnerSelect.vue │ └── View.vue ├── nuxt.config.ts ├── package.json ├── plugins │ └── store.ts ├── public │ ├── favicon.ico │ ├── og-image.jpg │ └── robots.txt ├── tsconfig.json └── utils │ ├── head.ts │ └── spinners.ts ├── pnpm-workspace.yaml ├── spa-loading-templates ├── bars-spinner.html ├── circle-spinner.html ├── dots-spinner.html ├── pulse-spinner.html ├── ring-bars-spinner.html ├── ring-dot-spinner.html ├── ring-spinner.html └── wave-spinner.html ├── svgs ├── bars-spinner.svg ├── circle-spinner.svg ├── dots-spinner.svg ├── pulse-spinner.svg ├── ring-bars-spinner.svg ├── ring-dot-spinner.svg ├── ring-spinner.svg └── wave-spinner.svg └── tests ├── cypress.config.ts ├── cypress └── support │ ├── commands.ts │ ├── component-index.html │ └── component.ts ├── package.json ├── specs ├── callbacks.cy.tsx ├── config.cy.tsx ├── dom.cy.tsx ├── router.cy.tsx ├── scoped-options.cy.tsx └── spinners.cy.tsx └── tsconfig.json /.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.x' 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 package 29 | run: pnpm build 30 | - name: Copy README and LICENSE 31 | run: cp README.md LICENSE packages/vue-global-loader 32 | - name: Pack 33 | run: cd packages/vue-global-loader && rm -rf *.tgz && npm pack 34 | - name: Publish 35 | run: cd packages/vue-global-loader && npm publish *.tgz --provenance --access public 36 | env: 37 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - '*' 8 | tags-ignore: 9 | - '*' 10 | workflow_call: 11 | 12 | jobs: 13 | cypress-run: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Install Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 20 21 | - uses: pnpm/action-setup@v2 22 | name: Install pnpm 23 | with: 24 | version: 8 25 | run_install: false 26 | - name: Install Cypress binaries 27 | run: npx cypress install 28 | - name: Install dependecies 29 | run: pnpm install 30 | - name: Build package 31 | run: pnpm build 32 | - name: Install package 33 | run: pnpm install 34 | - name: Run tests 35 | uses: cypress-io/github-action@v5 36 | timeout-minutes: 5 37 | with: 38 | component: true 39 | install: false 40 | working-directory: tests 41 | browser: chrome 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pnpm-debug.log* 2 | 3 | .vscode 4 | *.sublime-workspace 5 | *.sublime-project 6 | 7 | node_modules 8 | dist 9 | 10 | *.tgz 11 | 12 | .nuxt 13 | pnpm-lock.yaml 14 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 3, 6 | "trailingComma": "es5", 7 | "useTabs": false, 8 | "plugins": ["./node_modules/prettier-plugin-jsdoc/dist/index.js"], 9 | "overrides": [ 10 | { 11 | "files": "README.md", 12 | "options": { 13 | "printWidth": 80, 14 | "tabWidth": 2, 15 | "trailingComma": "none" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Global Loader 2 | 3 | ![npm](https://img.shields.io/npm/v/vue-global-loader?color=46c119) ![dependencies](https://img.shields.io/badge/dependencies-0-success) ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/smastrom/vue-global-loader/tests.yml?branch=main&label=tests) 4 | 5 | ### Global loaders made easy for Vue and Nuxt. 6 | 7 | [Live Demo](https://vue-global-loader.pages.dev/) — [Vite Example](https://stackblitz.com/edit/vitejs-vite-umqonc?file=src%2FApp.vue) — [Nuxt Example](https://stackblitz.com/edit/nuxt-starter-rpnobz?file=app.vue) 8 | 9 |
10 | 11 | > :bulb: Please note that this package only works with [Vite](https://vitejs.dev/) and [Nuxt](https://nuxt.com/) setups. Usage without a build-step is not supported. 12 | 13 |
14 | 15 | ## Intro 16 | 17 | I find it useful to display global loaders in single-page apps. For example: 18 | 19 | - When redirecting to an external payment page 20 | - When navigating to an internal page after a critical operation such as sign-in/sign-out 21 | - When navigating to an internal page with plenty of data 22 | 23 | Since I was re-creating the same logic and markup over and over again, I decided to publish a package for it. 24 | 25 | ## Features 26 | 27 | This package simplifies the usage of a single, top-level global loader by: 28 | 29 | - Installing a global store that sits above your routes, so you can control it between pages 30 | - Providing a practical API customizable via few key props (get started in 10 seconds) 31 | - Properly disable user interactions with the rest of the app while the loader is displayed 32 | - Announcing a screen reader message when the loader is displayed 33 | - Dynamically update options from anywhere in the app and apply options only to a specific loader 34 | 35 | ## Table of Contents 36 | 37 | - [Installation](#installation) 38 | - [Usage](#usage) 39 | - [Customization](#customization) 40 | - [Spinners](#spinners) 41 | - [Options](#options) 42 | - [Default Options](#default-options) 43 | - [Scoped Options](#scoped-options) 44 | - [Updating default options](#updating-default-options) 45 | - [Callbacks / Lifecycle](#callbacks--lifecycle) 46 | - [API](#api) 47 | - [Further Notes](#further-notes) 48 | - [When to use it](#when-to-use-it) 49 | - [When to not use it](#when-to-not-use-it) 50 | - [SPA Loading Templates](#spa-loading-templates) 51 | 52 | ## Installation 53 | 54 | ```bash 55 | pnpm add vue-global-loader 56 | ``` 57 | 58 | ```bash 59 | yarn add vue-global-loader 60 | ``` 61 | 62 | ```bash 63 | npm i vue-global-loader 64 | ``` 65 | 66 | ### Vite 67 | 68 | > :bulb: See [↓ below](#nuxt) for **Nuxt** 69 | 70 | **main.js** 71 | 72 | ```js 73 | import { createApp } from 'vue' 74 | import { globalLoader } from 'vue-global-loader' 75 | 76 | import App from './App.vue' 77 | 78 | const app = createApp(App) 79 | 80 | app.use(globalLoader, { 81 | // Options 82 | }) 83 | 84 | app.mount('#app') 85 | ``` 86 | 87 | **App.vue** 88 | 89 | ```vue 90 | 94 | 95 | 102 | ``` 103 | 104 | ### Nuxt 105 | 106 | **nuxt.config.ts** 107 | 108 | ```ts 109 | export default defineNuxtConfig({ 110 | modules: ['vue-global-loader/nuxt'], 111 | globalLoader: { 112 | // Options 113 | } 114 | }) 115 | ``` 116 | 117 | **app.vue** 118 | 119 | ```vue 120 | 127 | ``` 128 | 129 | ### Usage 130 | 131 | `pages/login.vue` 132 | 133 | > :bulb: No need to state the imports if using **Nuxt** (everything is auto-imported) 134 | 135 | ```vue 136 | 158 | 159 | 162 | ``` 163 | 164 | `pages/dashboard.vue` 165 | 166 | > :bulb: No need to state the imports if using **Nuxt** (everything is auto-imported) 167 | 168 | ```vue 169 | 187 | 188 | 193 | ``` 194 | 195 | ## Customization 196 | 197 | ### Spinners 198 | 199 | This package ships with 8 spinners that should cover most use cases: 200 | 201 | | | Import | 202 | | --------------------------------------------------- | --------------------------------------------------------------------- | 203 | | ![spinner](https://svgur.com/i/zKJ.svg) | `import CircleSpinner from 'vue-global-loader/CircleSpinner.vue'` | 204 | | ![ring-spinner](https://svgur.com/i/zJk.svg) | `import RingSpinner from 'vue-global-loader/RingSpinner.vue'` | 205 | | ![ring-dot-spinner](https://svgur.com/i/zKc.svg) | `import RingDotSpinner from 'vue-global-loader/RingDotSpinner.vue'` | 206 | | ![circle-bars-spinner](https://svgur.com/i/zHt.svg) | `import RingBarsSpinner from 'vue-global-loader/RingBarsSpinner.vue'` | 207 | | ![pulse-spinner](https://svgur.com/i/zKK.svg) | `import PulseSpinner from 'vue-global-loader/PulseSpinner.vue'` | 208 | | ![dots-spinner](https://svgur.com/i/zKf.svg) | `import DotsSpinner from 'vue-global-loader/DotsSpinner.vue'` | 209 | | ![bars-spinner](https://svgur.com/i/zHu.svg) | `import BarsSpinner from 'vue-global-loader/BarsSpinner.vue'` | 210 | | ![wave-spinner](https://svgur.com/i/zJ6.svg) | `import WaveSpinner from 'vue-global-loader/WaveSpinner.vue'` | 211 | 212 | Import the one you prefer and pass it to the default slot: 213 | 214 | > :bulb: No need to state the imports if using **Nuxt** (everything is auto-imported) 215 | 216 | ```vue 217 | 221 | 222 | 229 | ``` 230 | 231 | Each spinner already has its own CSS and inherits the `foregroundColor` option specified in your config or scoped options. 232 | 233 | If you think the spinner size is too big, add a class or inline styles to it and they'll be forwarded to the root `svg` element: 234 | 235 | ```vue 236 | 241 | ``` 242 | 243 | #### Custom Spinners 244 | 245 | To use your own spinner, pass a custom SVG (or whatever) to the default slot: 246 | 247 | ```vue 248 | 261 | 262 | 274 | ``` 275 | 276 | ### Options 277 | 278 | | Prop | Type | Description | Default | 279 | | --------------------- | -------- | ------------------------------------------------------------------ | ------------ | 280 | | `screenReaderMessage` | `string` | Message to announce when displaying the loader. | `Loading` | 281 | | `transitionDuration` | `number` | Enter/leave fade transition duration in ms. Set `0` to disable it. | `250` | 282 | | `foregroundColor` | `string` | Color of the spinner. | `#000` | 283 | | `backgroundColor` | `string` | Background color of the loading screen. | `#fff` | 284 | | `backgroundOpacity` | `number` | Background opacity of the loading screen. | `1` | 285 | | `backgroundBlur` | `number` | Background blur of the loading screen. | `0` | 286 | | `zIndex` | `number` | Z-index of the loading screen. | `2147483647` | 287 | 288 | #### Default Options 289 | 290 | To customize defaults, pass the options to the `globalLoader` plugin (if using Vite): 291 | 292 | **main.js** 293 | 294 | ```js 295 | app.use(globalLoader, { 296 | background: '#000', 297 | foreground: '#fff', 298 | screenReaderMessage: 'Loading, please wait...' 299 | }) 300 | ``` 301 | 302 | Or to the `globalLoader` config key (if using Nuxt): 303 | 304 | **nuxt.config.ts** 305 | 306 | ```ts 307 | export default defineNuxtConfig({ 308 | modules: ['vue-global-loader/nuxt'] 309 | globalLoader: { 310 | background: '#000', 311 | foreground: '#fff', 312 | screenReaderMessage: 'Loading, please wait...' 313 | } 314 | }) 315 | ``` 316 | 317 | #### Scoped Options 318 | 319 | You can define options for a specific loader via `useGlobalLoader` options, only the loader triggered here (when calling `displayLoader`) will have a different `backgroundOpacity` and `screenReaderMessage`): 320 | 321 | ```ts 322 | const { displayLoader, destroyLoader } = useGlobalLoader({ 323 | backgroundOpacity: 0.5, 324 | screenReaderMessage: 'Redirecting to payment page, please wait...' 325 | }) 326 | ``` 327 | 328 | #### Updating default options 329 | 330 | For convenience, you can set new defaults from anywhere in your Vue app using `updateOptions`: 331 | 332 | > :bulb: No need to state the imports if using **Nuxt** (everything is auto-imported) 333 | 334 | ```vue 335 | 356 | ``` 357 | 358 | ### Callbacks / Transitions Lifecycle 359 | 360 | The `GlobalLoader` lifecycle is handled using Vue's [Transition](https://vuejs.org/guide/built-ins/transition.html) hooks. For convenience, `displayLoader` and `destroyLoader` include some syntactic sugar to make it easier to execute code after the fade transition is completed. 361 | 362 | #### `displayLoader` 363 | 364 | This function returns a promise that resolves after the enter transition is completed or cancelled. 365 | 366 | ```ts 367 | const { displayLoader } = useGlobalLoader() 368 | const router = useRouter() 369 | 370 | await displayLoader() // Wait for the fade transition to complete... 371 | await signOut() // ...mutate the underlying UI 372 | router.push('/') // ...navigate to the homepage and call 'destroyLoader' there 373 | ``` 374 | 375 | #### `destroyLoader` 376 | 377 | This function doesn't return a promise but instead, it accepts a callback that is executed after the loader is destroyed. 378 | 379 | ```ts 380 | const { destroyLoader } = useGlobalLoader() 381 | 382 | destroyLoader(() => { 383 | console.log('Loader destroyed') 384 | }) 385 | ``` 386 | 387 | ## API 388 | 389 | ```ts 390 | interface GlobalLoaderOptions { 391 | screenReaderMessage: string 392 | transitionDuration: number 393 | foregroundColor: string 394 | backgroundColor: string 395 | backgroundOpacity: number 396 | backgroundBlur: number 397 | zIndex: number 398 | } 399 | 400 | declare function useGlobalLoader( 401 | scopedOptions?: Partial 402 | ): { 403 | displayLoader: () => Promise 404 | destroyLoader: (onDestroyed?: () => void) => void 405 | updateOptions: (newOptions: Partial) => void 406 | options: Readonly> 407 | isLoading: Readonly> 408 | } 409 | ``` 410 | 411 | ## Further Notes 412 | 413 | ### When to use it 414 | 415 | Use it when you think it's better for the user to not interact with the rest of the app or to not see what's happening in the UI while an expensive async operation **initiated by the user** is taking place. 416 | 417 | ### When to not use it 418 | 419 | - To display a loader while your app JS is loading. Use the [SPA Loading Templates](#spa-loading-templates) in plain HTML for that (see below). 420 | - Server-side rendered pages: they are already meant to send the proper content to the client and avoid spinners. 421 | - Non-critical async operations that are quick and obvious, in such case a local loader is better (e.g. spinner in the newsletter form submit button). 422 | - Async operations meant to feed the content of small sub-components, in such case [Suspense](https://vuejs.org/guide/built-ins/suspense.html) is preferred. 423 | 424 | ## SPA Loading Templates 425 | 426 | For convenience, ready-made HTML templates for each spinner shipped with this package are available in [this folder](https://github.com/smastrom/vue-global-loader/tree/main/spa-loading-templates). 427 | 428 | They can be used to display a global loader that has the same appearance of the one used in your app to be displayed while the app JS is loading. 429 | 430 | ### Vite 431 | 432 | Copy/paste the file content markup as a direct child of the `body` in the `index.html` file and remove it in a top-level (App.vue) _onMounted_ hook via `document.getElementById('spa_loading_template').remove()`. 433 | 434 | ### Nuxt 435 | 436 | Download the html file you prefer, rename it to `spa-loading-template.html` and place it in `@/app/spa-loading-template.html`. 437 | 438 | ## Thanks 439 | 440 | [@n3r4zzurr0](https://github.com/n3r4zzurr0) for the awesome spinners. 441 | 442 | ## License 443 | 444 | MIT 445 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-global-loader-monorepo", 3 | "private": true, 4 | "packageManager": "pnpm@8.10.2", 5 | "engines": { 6 | "node": ">=20.0.0" 7 | }, 8 | "scripts": { 9 | "dev": "rm -rf dist && concurrently \"pnpm -C packages/vue-global-loader run watch\" \"pnpm -C playground install && pnpm -C playground run dev --host\"", 10 | "build": "pnpm -C packages/vue-global-loader run build", 11 | "build:playground": "pnpm build && pnpm -C playground run build", 12 | "test": "pnpm build && pnpm -C tests install && pnpm -C tests run test", 13 | "test:gui": "rm -rf dist && concurrently \"pnpm -C packages/vue-global-loader run watch\" \"pnpm -C tests install && pnpm -C tests run test:gui\"", 14 | "prepare": "husky install" 15 | }, 16 | "devDependencies": { 17 | "concurrently": "^8.2.2", 18 | "husky": "^8.0.3", 19 | "lint-staged": "^15.2.0", 20 | "prettier": "^3.1.1", 21 | "prettier-plugin-jsdoc": "^1.1.1" 22 | }, 23 | "lint-staged": { 24 | "*.{js,ts,vue,json,css,md}": "prettier --write" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/vue-global-loader/.gitignore: -------------------------------------------------------------------------------- 1 | README.md 2 | 3 | LICENSE 4 | -------------------------------------------------------------------------------- /packages/vue-global-loader/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "mainEntryPointFilePath": "./dist/index.d.ts", 4 | "compiler": { 5 | "tsconfigFilePath": "tsconfig.build.json" 6 | }, 7 | "docModel": { 8 | "enabled": false 9 | }, 10 | "tsdocMetadata": { 11 | "enabled": false 12 | }, 13 | "apiReport": { 14 | "enabled": false 15 | }, 16 | "dtsRollup": { 17 | "enabled": true, 18 | "untrimmedFilePath": "./dist/index.d.ts" 19 | }, 20 | "messages": { 21 | "extractorMessageReporting": { 22 | "default": { 23 | "logLevel": "warning" 24 | } 25 | }, 26 | "tsdocMessageReporting": { 27 | "default": { 28 | "logLevel": "warning" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/vue-global-loader/build.js: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | 3 | import { exec, execSync } from 'child_process' 4 | import { readFileSync } from 'node:fs' 5 | 6 | /** @type {import('esbuild').Plugin} */ 7 | const timePlugin = { 8 | name: 'time', 9 | setup({ onStart, onEnd }) { 10 | /** @type number */ 11 | let start 12 | onStart(() => { 13 | start = Date.now() 14 | }) 15 | onEnd(() => { 16 | console.log(`[esbuild] - Bundled in ${Date.now() - start}ms`) 17 | }) 18 | }, 19 | } 20 | 21 | /** @type {import('esbuild').Plugin} */ 22 | const dtsPlugin = { 23 | name: 'dts', 24 | setup({ onEnd }) { 25 | /** @type {import('child_process').ChildProcess | null} */ 26 | let childProcess = null 27 | 28 | function declare() { 29 | const start = Date.now() 30 | console.log('[esbuild-dts] - Bundling declaration...') 31 | 32 | childProcess = exec( 33 | 'tsc -p tsconfig.build.json && npx api-extractor run --local', 34 | (err) => { 35 | if (err && err.signal !== 'SIGTERM') console.error(err) 36 | } 37 | ) 38 | 39 | childProcess.on('exit', (code, signal) => { 40 | if (code === 0) { 41 | console.log(`[esbuild-dts] - Declaration complete in ${Date.now() - start}ms`) 42 | 43 | if (process.env.IS_BUILD) { 44 | execSync('rm -rf dist/core') 45 | console.log('[esbuild-dts] - Deleted useless declaration files') 46 | } 47 | } else if (signal === 'SIGTERM') { 48 | console.log('[esbuild-dts] - Declaration aborted, too fast :P') 49 | } 50 | 51 | childProcess = null 52 | }) 53 | } 54 | 55 | onEnd(() => { 56 | if (childProcess) childProcess.kill() 57 | setTimeout(declare) 58 | }) 59 | }, 60 | } 61 | 62 | /** 63 | * Just to be extra sure in case I forget { drop: ['console'] } in esbuild options 64 | * 65 | * @type {import('esbuild').Plugin} 66 | */ 67 | const checkConsolePlugin = { 68 | name: 'check-console', 69 | setup({ onEnd }) { 70 | onEnd(() => { 71 | if (!process.env.IS_BUILD) return 72 | const indexJs = readFileSync('dist/index.js', 'utf-8') 73 | 74 | if (indexJs.includes('console.')) { 75 | throw new Error('[esbuild-check-console] - Console statements present in index.js') 76 | } else { 77 | console.log( 78 | '[esbuild-check-console] - No console statements present in index.js, all good!' 79 | ) 80 | } 81 | }) 82 | }, 83 | } 84 | 85 | /** @type {import('esbuild').BuildOptions} */ 86 | export const buildOptions = { 87 | entryPoints: ['index.ts'], 88 | bundle: true, 89 | format: 'esm', 90 | target: 'es2015', 91 | minify: false, 92 | outfile: 'dist/index.js', 93 | external: ['vue'], 94 | plugins: [timePlugin, dtsPlugin, checkConsolePlugin], 95 | } 96 | 97 | await esbuild.build({ ...buildOptions, drop: ['console'] }) 98 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/GlobalLoader.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/GlobalLoaderClientOnly.js: -------------------------------------------------------------------------------- 1 | import { createElementBlock, defineComponent, onMounted, ref } from 'vue' 2 | 3 | export default defineComponent({ 4 | name: 'GlobalLoaderClientOnly', 5 | setup(_, { slots, attrs }) { 6 | const isMounted = ref(false) 7 | 8 | onMounted(() => (isMounted.value = true)) 9 | 10 | return () => { 11 | if (isMounted.value) return slots.default?.() 12 | 13 | return createElementBlock('span', attrs, '') 14 | } 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/GlobalLoaderImpl.vue: -------------------------------------------------------------------------------- 1 | 144 | 145 | 167 | 168 | 213 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare const _default: import('vue').DefineComponent<{}, {}, any> 2 | 3 | export { _default as default } 4 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/BarsSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 68 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/CircleSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 69 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/DotsSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 63 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/PulseSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 52 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/RingBarsSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 84 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/RingDotSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | 17 | 52 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/RingSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | 20 | 55 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/WaveSpinner.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 234 | -------------------------------------------------------------------------------- /packages/vue-global-loader/components/spinners/index.d.ts: -------------------------------------------------------------------------------- 1 | export declare const _default: import('vue').DefineComponent<{}, {}, any> 2 | 3 | export { _default as default } 4 | -------------------------------------------------------------------------------- /packages/vue-global-loader/core/constants.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalLoaderOptions } from './types' 2 | 3 | export const DEFAULT_OPTIONS: GlobalLoaderOptions = { 4 | screenReaderMessage: 'Loading', 5 | transitionDuration: 250, 6 | foregroundColor: '#000', 7 | backgroundColor: '#fff', 8 | backgroundOpacity: 1, 9 | backgroundBlur: 0, 10 | zIndex: 2147483647, 11 | } 12 | -------------------------------------------------------------------------------- /packages/vue-global-loader/core/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { App } from 'vue' 2 | import type { GlobalLoaderOptions } from './types' 3 | 4 | import { GlobalLoaderStore, injectionKey } from './store' 5 | 6 | export const globalLoader = { 7 | install(app: App, options: Partial = {}) { 8 | app.provide(injectionKey, new GlobalLoaderStore(options)) 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /packages/vue-global-loader/core/store.ts: -------------------------------------------------------------------------------- 1 | import type { GlobalLoaderOptions } from './types' 2 | 3 | import { reactive, readonly, ref, inject, type InjectionKey } from 'vue' 4 | import { DEFAULT_OPTIONS } from './constants' 5 | import { isSSR, noop } from './utils' 6 | 7 | export const injectionKey = Symbol('') as InjectionKey 8 | 9 | export class GlobalLoaderStore { 10 | options: GlobalLoaderOptions 11 | 12 | prevOptions: GlobalLoaderOptions = { ...DEFAULT_OPTIONS } 13 | isLoading = ref(false) 14 | 15 | onDestroyedCb = noop 16 | onDisplayedResolve = noop 17 | 18 | constructor(pluginConfig: Partial) { 19 | this.options = reactive(Object.assign({ ...DEFAULT_OPTIONS }, pluginConfig)) 20 | } 21 | 22 | setOptions(newOptions: Partial) { 23 | Object.assign(this.options, newOptions) 24 | } 25 | 26 | setPrevOptions(_prevOptions: Partial) { 27 | Object.assign(this.prevOptions, _prevOptions) 28 | } 29 | 30 | setIsLoading(value: boolean) { 31 | this.isLoading.value = value 32 | } 33 | 34 | displayLoader(scopedOptions: Partial = {}) { 35 | return new Promise((resolve) => { 36 | if (this.isLoading.value) { 37 | resolve() 38 | return 39 | } 40 | 41 | this.setPrevOptions(this.options) 42 | console.log('[global-loader] - Saved global options (prev)!') 43 | 44 | this.setOptions(scopedOptions) 45 | console.log('[global-loader] - Set scoped options!') 46 | 47 | this.setIsLoading(true) 48 | 49 | this.onDisplayedResolve = resolve 50 | }) 51 | } 52 | 53 | destroyLoader(extOnDestroyed?: () => void) { 54 | if (!this.isLoading.value) return 55 | 56 | console.log('[global-loader] - Destroying loader...') 57 | 58 | this.onDestroyedCb = typeof extOnDestroyed === 'function' ? extOnDestroyed : noop 59 | 60 | this.setIsLoading(false) 61 | } 62 | 63 | onDestroyed() { 64 | this.onDestroyedCb() 65 | this.setOptions(this.prevOptions) 66 | 67 | console.log('[global-loader] - Loader destroyed, options restored!') 68 | } 69 | } 70 | 71 | export function useGlobalLoader(scopedOptions: Partial = {}) { 72 | if (isSSR) { 73 | return { 74 | displayLoader: () => Promise.resolve(), 75 | destroyLoader: noop, 76 | updateOptions: noop, 77 | __onDestroyed: noop, 78 | __onDisplayed: noop, 79 | options: readonly(DEFAULT_OPTIONS), 80 | isLoading: readonly(ref(false)), 81 | } 82 | } 83 | 84 | const store = inject(injectionKey, new GlobalLoaderStore(scopedOptions)) 85 | 86 | return { 87 | /** Display the global loader with any scoped option set in `useGlobalLoader` parameter. */ 88 | displayLoader: () => store.displayLoader(scopedOptions), 89 | /** Destroy any active loader and restore global loader options. */ 90 | destroyLoader: (onDestroy?: () => void) => store.destroyLoader(onDestroy), 91 | /** Update the global loader default options. */ 92 | updateOptions: (options: Partial) => store.setOptions(options), 93 | /** @internal This method is used internally by the plugin and should not be used by the user. */ 94 | __onDestroyed: () => store.onDestroyed(), 95 | /** @internal This method is used internally by the plugin and should not be used by the user. */ 96 | __onDisplayed: () => store.onDisplayedResolve(), 97 | /** Reactive read-only global loader options. */ 98 | options: readonly(store.options), 99 | /** Reactive read-only global loader current state. */ 100 | isLoading: readonly(store.isLoading), 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/vue-global-loader/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSProperties } from 'vue' 2 | import type { GlobalLoaderStore } from './store' 3 | 4 | export interface GlobalLoaderOptions { 5 | screenReaderMessage: string 6 | transitionDuration: number 7 | foregroundColor: string 8 | backgroundColor: string 9 | backgroundOpacity: number 10 | backgroundBlur: number 11 | zIndex: number 12 | } 13 | 14 | export interface GlobalLoaderCSSVars extends CSSProperties { 15 | '--v-gl-fg-color': string 16 | '--v-gl-bg-color': string 17 | '--v-gl-bg-opacity': number 18 | '--v-gl-bg-blur': string 19 | '--v-gl-t-dur': string 20 | '--v-gl-z': number 21 | } 22 | 23 | export type { GlobalLoaderStore } 24 | -------------------------------------------------------------------------------- /packages/vue-global-loader/core/utils.ts: -------------------------------------------------------------------------------- 1 | export const isSSR = typeof window === 'undefined' 2 | 3 | export const noop = () => {} 4 | -------------------------------------------------------------------------------- /packages/vue-global-loader/index.ts: -------------------------------------------------------------------------------- 1 | export { globalLoader } from './core/plugin' 2 | 3 | export { useGlobalLoader } from './core/store' 4 | 5 | export { DEFAULT_OPTIONS } from './core/constants' 6 | 7 | export type * from './core/types' 8 | -------------------------------------------------------------------------------- /packages/vue-global-loader/nuxt/index.d.ts: -------------------------------------------------------------------------------- 1 | import { ModuleOptions } from './module' 2 | 3 | interface ModuleField { 4 | ['globalLoader']?: ModuleOptions 5 | } 6 | 7 | declare module '@nuxt/schema' { 8 | interface NuxtConfig extends ModuleField {} 9 | interface NuxtOptions extends ModuleField {} 10 | interface PublicRuntimeConfig extends ModuleField {} 11 | } 12 | 13 | declare module 'nuxt/schema' { 14 | interface NuxtConfig extends ModuleField {} 15 | interface NuxtOptions extends ModuleField {} 16 | interface PublicRuntimeConfig extends ModuleField {} 17 | } 18 | 19 | export { ModuleOptions, default } from './module' 20 | -------------------------------------------------------------------------------- /packages/vue-global-loader/nuxt/module.cjs: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | module.exports = function (...args) { 3 | // @ts-ignore 4 | return import('./module.mjs').then((m) => m.default.call(this, ...args)) 5 | } 6 | 7 | const _meta = (module.exports.meta = require('./module.json')) 8 | module.exports.getMeta = () => Promise.resolve(_meta) 9 | -------------------------------------------------------------------------------- /packages/vue-global-loader/nuxt/module.d.ts: -------------------------------------------------------------------------------- 1 | import * as _nuxt_schema from '@nuxt/schema' 2 | 3 | // Somehow types if imported from the package are not recognized so they must be hardcoded. TODO: investigate 4 | interface ModuleOptions { 5 | /** 6 | * Whether to create and inject the global loader store in the Vue app. Equivalent of calling 7 | * `app.use(globalLoader)` in the main.js of a non-Nuxt app. 8 | */ 9 | addPlugin: boolean 10 | screenReaderMessage: string 11 | transitionDuration: number 12 | foregroundColor: string 13 | backgroundColor: string 14 | backgroundOpacity: number 15 | backgroundBlur: number 16 | zIndex: number 17 | } 18 | 19 | declare const _default: _nuxt_schema.NuxtModule 20 | 21 | export { ModuleOptions, _default as default } 22 | -------------------------------------------------------------------------------- /packages/vue-global-loader/nuxt/module.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-global-loader/nuxt", 3 | "configKey": "globalLoader", 4 | "version": "0.9.9" 5 | } 6 | -------------------------------------------------------------------------------- /packages/vue-global-loader/nuxt/module.mjs: -------------------------------------------------------------------------------- 1 | import { 2 | defineNuxtModule, 3 | addPluginTemplate, 4 | addComponentsDir, 5 | addImports, 6 | createResolver, 7 | addComponent, 8 | extendViteConfig, 9 | } from '@nuxt/kit' 10 | import { defu } from 'defu' 11 | 12 | export default defineNuxtModule({ 13 | meta: { 14 | name: 'nuxt/vue-global-loader', 15 | configKey: 'globalLoader', 16 | compatibility: { 17 | nuxt: '>=3.0.0', 18 | }, 19 | }, 20 | setup(moduleOptions, nuxt) { 21 | nuxt.options.runtimeConfig.public.globalLoader = defu( 22 | nuxt.options.runtimeConfig.public.globalLoader || {}, 23 | moduleOptions 24 | ) 25 | 26 | if (nuxt.options.runtimeConfig.public.globalLoader.addPlugin !== false) { 27 | addPluginTemplate({ 28 | filename: '001.vue-global-loader.client.mjs', 29 | getContents() { 30 | return ` 31 | import { globalLoader } from 'vue-global-loader' 32 | import { defineNuxtPlugin, useRuntimeConfig } from '#imports' 33 | 34 | export default defineNuxtPlugin(({ vueApp }) => { 35 | const options = useRuntimeConfig().public?.globalLoader || {} 36 | 37 | vueApp.use(globalLoader, options) 38 | }) 39 | ` 40 | }, 41 | }) 42 | } 43 | 44 | const resolver = createResolver(import.meta.url) 45 | 46 | addImports({ name: 'useGlobalLoader', as: 'useGlobalLoader', from: 'vue-global-loader' }) 47 | 48 | addComponentsDir({ 49 | path: resolver.resolve('../components/spinners'), 50 | extensions: ['vue'], 51 | }) 52 | 53 | addComponent({ 54 | name: 'GlobalLoader', 55 | filePath: resolver.resolve('../components/GlobalLoader.vue'), 56 | }) 57 | 58 | extendViteConfig((config) => { 59 | config.optimizeDeps ||= {} 60 | config.optimizeDeps.include ||= [] 61 | 62 | if (!config.optimizeDeps.include.includes('vue-global-loader')) { 63 | config.optimizeDeps.include.push('vue-global-loader') 64 | } 65 | }) 66 | }, 67 | }) 68 | -------------------------------------------------------------------------------- /packages/vue-global-loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-global-loader", 3 | "version": "0.9.9", 4 | "private": false, 5 | "description": "Global loaders made easy for Vue and Nuxt", 6 | "keywords": [ 7 | "vue", 8 | "vuejs", 9 | "nuxt", 10 | "nuxtjs", 11 | "loading", 12 | "loading-screen", 13 | "loader", 14 | "spinner", 15 | "spinners" 16 | ], 17 | "homepage": "https://vue-global-loader.pages.dev/", 18 | "bugs": { 19 | "url": "https://github.com/smastrom/vue-global-loader/issues" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/smastrom/vue-global-loader.git", 24 | "directory": "packages/vue-global-loader" 25 | }, 26 | "license": "MIT", 27 | "author": { 28 | "name": "Simone Mastromattei", 29 | "email": "smastrom@proton.me" 30 | }, 31 | "sideEffects": false, 32 | "type": "module", 33 | "exports": { 34 | "./GlobalLoader.vue": { 35 | "import": "./components/GlobalLoader.vue", 36 | "types": "./components/index.d.ts" 37 | }, 38 | "./BarsSpinner.vue": { 39 | "import": "./components/spinners/BarsSpinner.vue", 40 | "types": "./components/spinners/index.d.ts" 41 | }, 42 | "./CircleSpinner.vue": { 43 | "import": "./components/spinners/CircleSpinner.vue", 44 | "types": "./components/spinners/index.d.ts" 45 | }, 46 | "./DotsSpinner.vue": { 47 | "import": "./components/spinners/DotsSpinner.vue", 48 | "types": "./components/spinners/index.d.ts" 49 | }, 50 | "./PulseSpinner.vue": { 51 | "import": "./components/spinners/PulseSpinner.vue", 52 | "types": "./components/spinners/index.d.ts" 53 | }, 54 | "./RingSpinner.vue": { 55 | "import": "./components/spinners/RingSpinner.vue", 56 | "types": "./components/spinners/index.d.ts" 57 | }, 58 | "./RingBarsSpinner.vue": { 59 | "import": "./components/spinners/RingBarsSpinner.vue", 60 | "types": "./components/spinners/index.d.ts" 61 | }, 62 | "./RingDotSpinner.vue": { 63 | "import": "./components/spinners/RingDotSpinner.vue", 64 | "types": "./components/spinners/index.d.ts" 65 | }, 66 | "./WaveSpinner.vue": { 67 | "import": "./components/spinners/WaveSpinner.vue", 68 | "types": "./components/spinners/index.d.ts" 69 | }, 70 | "./nuxt": { 71 | "import": "./nuxt/module.mjs", 72 | "require": "./nuxt/module.cjs", 73 | "types": "./nuxt/index.d.ts" 74 | }, 75 | ".": { 76 | "types": "./dist/index.d.ts", 77 | "import": "./dist/index.js" 78 | } 79 | }, 80 | "module": "dist/index.js", 81 | "types": "dist/index.d.ts", 82 | "files": [ 83 | "dist/*", 84 | "components/*", 85 | "nuxt/*" 86 | ], 87 | "scripts": { 88 | "prebuild": "cp ../../README.md ../../LICENSE .", 89 | "build": "rm -rf dist && IS_BUILD=true node build.js", 90 | "watch": "rm -rf dist && node watch.js", 91 | "postbuild": "pnpm pack" 92 | }, 93 | "devDependencies": { 94 | "@microsoft/api-extractor": "^7.38.5", 95 | "@nuxt/kit": "^3.8.2", 96 | "@nuxt/schema": "^3.8.2", 97 | "@types/node": "^20.10.4", 98 | "concurrently": "^8.2.2", 99 | "defu": "^6.1.3", 100 | "esbuild": "^0.19.9", 101 | "typescript": "5.2.2", 102 | "vue": "^3.3.11" 103 | }, 104 | "peerDependencies": { 105 | "@nuxt/kit": ">=3.0.0", 106 | "@nuxt/schema": ">=3.0.0", 107 | "defu": ">=5" 108 | }, 109 | "peerDependenciesMeta": { 110 | "@nuxt/kit": { 111 | "optional": true 112 | }, 113 | "@nuxt/schema": { 114 | "optional": true 115 | }, 116 | "defu": { 117 | "optional": true 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /packages/vue-global-loader/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["./index.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/vue-global-loader/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2018", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "outDir": "dist", 7 | "declaration": true, 8 | "strict": true, 9 | "isolatedModules": true, 10 | "lib": ["ESNext", "ES2018", "DOM"], 11 | "resolveJsonModule": true, 12 | "skipLibCheck": true, 13 | "emitDeclarationOnly": true, 14 | "allowJs": true, 15 | "checkJs": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "vue-global-loader": ["./index.ts"] 19 | } 20 | }, 21 | "include": ["."], 22 | "exclude": ["dist"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/vue-global-loader/watch.js: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | 3 | import { buildOptions } from './build.js' 4 | 5 | await esbuild.context(buildOptions).then((ctx) => ctx.watch()) 6 | -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /playground/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 42 | -------------------------------------------------------------------------------- /playground/assets/global.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-0: #fffef7; 3 | --light-1: #fffded; 4 | --light-2: #e5e0d2; 5 | --light-3: #e1dac8; 6 | --light-4: #d7d0bd; 7 | --light-5: #e4dfbe; 8 | 9 | --dark-0: #5a4827; 10 | --dark-1: #847353; 11 | 12 | --size-02: 0.25rem; 13 | --size-05: 0.5rem; 14 | --size-07: 0.75rem; 15 | --size-08: 0.825rem; 16 | --size-09: 0.925rem; 17 | --size-1: 1rem; 18 | --size-2: 1.125rem; 19 | --size-3: 1.25rem; 20 | --size-4: 1.5rem; 21 | --size-5: 1.75rem; 22 | --size-6: 2rem; 23 | --size-7: 2.25rem; 24 | --size-8: 2.5rem; 25 | } 26 | 27 | @font-face { 28 | font-family: 'Golos Text'; 29 | font-style: normal; 30 | font-weight: 400; 31 | font-display: swap; 32 | src: url('@/assets/golos-text-v4-latin-regular.woff2') format('woff2'); 33 | } 34 | 35 | @font-face { 36 | font-family: 'Golos Text'; 37 | font-style: normal; 38 | font-weight: 700; 39 | font-display: swap; 40 | src: url('@/assets/golos-text-v4-latin-700.woff2') format('woff2'); 41 | } 42 | 43 | *, 44 | *::before, 45 | *::after { 46 | box-sizing: border-box; 47 | } 48 | 49 | html { 50 | font-size: 100%; 51 | text-rendering: optimizeLegibility; 52 | -webkit-font-smoothing: antialiased; 53 | -moz-osx-font-smoothing: grayscale; 54 | touch-action: manipulation; 55 | -webkit-tap-highlight-color: transparent; 56 | } 57 | 58 | body { 59 | background-color: var(--light-3); 60 | margin: 0; 61 | padding: 0; 62 | font-family: 'Golos Text', sans-serif; 63 | } 64 | 65 | #__nuxt { 66 | display: flex; 67 | flex-direction: column; 68 | justify-content: center; 69 | align-items: center; 70 | min-height: 100vh; 71 | min-height: 100svh; 72 | } 73 | 74 | h1, 75 | h2, 76 | h3, 77 | p { 78 | margin: 0; 79 | padding: 0; 80 | } 81 | -------------------------------------------------------------------------------- /playground/assets/golos-text-v4-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/vue-global-loader/67d7bdb7577b9282996646ef1e7bf432c6da8e10/playground/assets/golos-text-v4-latin-700.woff2 -------------------------------------------------------------------------------- /playground/assets/golos-text-v4-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/vue-global-loader/67d7bdb7577b9282996646ef1e7bf432c6da8e10/playground/assets/golos-text-v4-latin-regular.woff2 -------------------------------------------------------------------------------- /playground/components/ChevronIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 21 | -------------------------------------------------------------------------------- /playground/components/InstallationCommands.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 42 | 43 | 84 | -------------------------------------------------------------------------------- /playground/components/SpinnerSelect.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 28 | 29 | 137 | -------------------------------------------------------------------------------- /playground/components/View.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 112 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import browserslist from 'browserslist' 2 | import { browserslistToTargets } from 'lightningcss' 3 | 4 | import { getHead } from './utils/head' 5 | 6 | export default defineNuxtConfig({ 7 | modules: ['vue-global-loader/nuxt'], 8 | css: ['@/assets/global.css'], 9 | devtools: { 10 | enabled: true, 11 | }, 12 | nitro: { 13 | preset: 'cloudflare-pages', 14 | }, 15 | app: { 16 | head: getHead(), 17 | }, 18 | vite: { 19 | css: { 20 | transformer: 'lightningcss', 21 | lightningcss: { 22 | targets: browserslistToTargets(browserslist('>= 0.25%')), 23 | drafts: { 24 | nesting: true, 25 | }, 26 | }, 27 | }, 28 | }, 29 | globalLoader: { 30 | // addPlugin: true, 31 | transitionDuration: 300, 32 | backgroundColor: 'var(--light-1)', 33 | foregroundColor: 'var(--dark-1)', 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-global-loader-playground", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "npx nuxi cleanup && nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare" 11 | }, 12 | "devDependencies": { 13 | "@nuxt/devtools": "^1.2.0", 14 | "browserslist": "^4.23.0", 15 | "lightningcss": "^1.24.1", 16 | "nuxt": "^3.11.2", 17 | "vue": "^3.4.26", 18 | "vue-router": "^4.3.2" 19 | }, 20 | "dependencies": { 21 | "vue-global-loader": "workspace:^" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /playground/plugins/store.ts: -------------------------------------------------------------------------------- 1 | type Spinner = keyof typeof pkgSpinners 2 | 3 | export default defineNuxtPlugin(() => { 4 | const router = useRouter() 5 | const route = useRoute() 6 | 7 | let initialKey: Spinner = Object.keys(pkgSpinners)[0] as Spinner 8 | 9 | if ((route.query.spinner || '').toString() in pkgSpinners) { 10 | initialKey = route.query.spinner as Spinner 11 | } 12 | 13 | const activeSpinner = ref(initialKey) 14 | 15 | watch(activeSpinner, (key) => { 16 | if (key in pkgSpinners) router.replace({ query: { spinner: key } }) 17 | }) 18 | 19 | return { 20 | provide: { 21 | store: { 22 | activeSpinner, 23 | }, 24 | }, 25 | } 26 | }) 27 | -------------------------------------------------------------------------------- /playground/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/vue-global-loader/67d7bdb7577b9282996646ef1e7bf432c6da8e10/playground/public/favicon.ico -------------------------------------------------------------------------------- /playground/public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smastrom/vue-global-loader/67d7bdb7577b9282996646ef1e7bf432c6da8e10/playground/public/og-image.jpg -------------------------------------------------------------------------------- /playground/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/utils/head.ts: -------------------------------------------------------------------------------- 1 | const siteName = 'Vue Global Loader' 2 | const description = 'Global loaders made easy for Vue and Nuxt.' 3 | 4 | export function getHead() { 5 | return { 6 | title: `${siteName} - ${description}`, 7 | link: [ 8 | { 9 | rel: 'icon', 10 | href: '/favicon.ico', 11 | }, 12 | ], 13 | htmlAttrs: { 14 | lang: 'en', 15 | }, 16 | meta: [ 17 | { 18 | hid: 'description', 19 | name: 'description', 20 | content: description, 21 | }, 22 | { 23 | hid: 'og:title', 24 | property: 'og:title', 25 | content: `${siteName} - ${description}`, 26 | }, 27 | { 28 | hid: 'og:description', 29 | property: 'og:description', 30 | content: description, 31 | }, 32 | { 33 | hid: 'og:image', 34 | property: 'og:image', 35 | content: '/og-image.jpg', 36 | }, 37 | { 38 | hid: 'og:url', 39 | property: 'og:url', 40 | content: 'https://vue-global-loader.pages.dev', 41 | }, 42 | { 43 | hid: 'twitter:title', 44 | name: 'twitter:title', 45 | content: `${siteName} - ${description}`, 46 | }, 47 | { 48 | hid: 'twitter:description', 49 | name: 'twitter:description', 50 | content: description, 51 | }, 52 | 53 | { 54 | hid: 'twitter:image', 55 | name: 'twitter:image', 56 | content: '/og-image.jpg', 57 | }, 58 | { 59 | hid: 'twitter:card', 60 | name: 'twitter:card', 61 | content: 'summary_large_image', 62 | }, 63 | ], 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /playground/utils/spinners.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CircleSpinner, 3 | RingSpinner, 4 | RingDotSpinner, 5 | RingBarsSpinner, 6 | PulseSpinner, 7 | BarsSpinner, 8 | DotsSpinner, 9 | WaveSpinner, 10 | } from '#components' 11 | 12 | export const pkgSpinners = { 13 | CircleSpinner, 14 | RingSpinner, 15 | RingDotSpinner, 16 | RingBarsSpinner, 17 | PulseSpinner, 18 | BarsSpinner, 19 | DotsSpinner, 20 | WaveSpinner, 21 | } 22 | 23 | export function useStore() { 24 | return useNuxtApp().$store 25 | } 26 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/*' 3 | - 'playground' 4 | - 'tests' 5 | -------------------------------------------------------------------------------- /spa-loading-templates/bars-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 7 | 8 | 71 |
72 | -------------------------------------------------------------------------------- /spa-loading-templates/circle-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 74 |
75 | -------------------------------------------------------------------------------- /spa-loading-templates/dots-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 7 | 8 | 68 |
69 | -------------------------------------------------------------------------------- /spa-loading-templates/pulse-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 58 |
59 | -------------------------------------------------------------------------------- /spa-loading-templates/ring-bars-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 11 | 12 | 89 |
90 | -------------------------------------------------------------------------------- /spa-loading-templates/ring-dot-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 9 | 10 | 57 |
58 | -------------------------------------------------------------------------------- /spa-loading-templates/ring-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 12 | 13 | 60 |
61 | -------------------------------------------------------------------------------- /spa-loading-templates/wave-spinner.html: -------------------------------------------------------------------------------- 1 |
2 | 13 | 14 | 223 |
224 | -------------------------------------------------------------------------------- /svgs/bars-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /svgs/circle-spinner.svg: -------------------------------------------------------------------------------- 1 | 9 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /svgs/dots-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /svgs/pulse-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /svgs/ring-bars-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /svgs/ring-dot-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 19 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /svgs/ring-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 19 | 23 | 27 | 28 | -------------------------------------------------------------------------------- /svgs/wave-spinner.svg: -------------------------------------------------------------------------------- 1 | 8 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | -------------------------------------------------------------------------------- /tests/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | import { resolve } from 'path' 3 | import vueJsx from '@vitejs/plugin-vue-jsx' 4 | 5 | import vue from '@vitejs/plugin-vue' 6 | 7 | export default defineConfig({ 8 | video: false, 9 | viewportWidth: 1280, 10 | viewportHeight: 720, 11 | experimentalMemoryManagement: true, 12 | component: { 13 | devServer: { 14 | framework: 'vue', 15 | bundler: 'vite', 16 | viteConfig: { 17 | optimizeDeps: { 18 | include: ['vue-global-loader'], 19 | }, 20 | server: { 21 | port: 5176, 22 | }, 23 | resolve: { 24 | alias: { 25 | '@': resolve(__dirname, './'), 26 | }, 27 | }, 28 | plugins: [vue(), vueJsx()], 29 | }, 30 | }, 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /tests/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/vue' 2 | import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router' 3 | import tinycolor from 'tinycolor2' 4 | 5 | import { globalLoader, type GlobalLoaderOptions } from 'vue-global-loader' 6 | 7 | declare global { 8 | namespace Cypress { 9 | interface Chainable { 10 | mountApp( 11 | app: any, 12 | config?: Partial, 13 | routes?: RouteRecordRaw[] 14 | ): Chainable 15 | getRoot(): Chainable 16 | checkCssVars(config: Omit): Chainable 17 | checkComputedStyles( 18 | config: Omit 19 | ): Chainable 20 | checkDomAttrs(state: 'displayed' | 'destroyed'): Chainable 21 | triggerAppEvent(eventName: string): Chainable 22 | } 23 | } 24 | } 25 | 26 | Cypress.Commands.add( 27 | 'mountApp', 28 | (app: any, config: Partial = {}, routes = []) => { 29 | const router = createRouter({ 30 | history: createMemoryHistory(), 31 | routes, 32 | }) 33 | 34 | return mount(app, { 35 | global: { 36 | plugins: [ 37 | { 38 | install(app) { 39 | app.use(globalLoader, config) 40 | app.use(router) 41 | }, 42 | }, 43 | ], 44 | stubs: { 45 | // https://github.com/vuejs/vue-test-utils/issues/890 46 | transition: false, 47 | }, 48 | }, 49 | }) 50 | } 51 | ) 52 | 53 | Cypress.Commands.add('getRoot', () => cy.get('[data-cy-loader]')) 54 | 55 | Cypress.Commands.add('checkCssVars', { prevSubject: 'element' }, (subject, config) => { 56 | cy.wrap(subject) 57 | .should('have.attr', 'style') 58 | .and('include', '--v-gl-bg-color: ' + config.backgroundColor) 59 | .and('include', '--v-gl-bg-opacity: ' + config.backgroundOpacity) 60 | .and('include', '--v-gl-bg-blur: ' + config.backgroundBlur) 61 | .and('include', '--v-gl-fg-color: ' + config.foregroundColor) 62 | .and('include', '--v-gl-t-dur: ' + config.transitionDuration) 63 | .and('include', '--v-gl-z: ' + config.zIndex) 64 | 65 | return cy.wrap(subject) 66 | }) 67 | 68 | Cypress.Commands.add('checkComputedStyles', { prevSubject: 'element' }, (subject, config) => { 69 | let shouldPass = false 70 | 71 | cy.wrap(subject) 72 | .should('have.css', 'backdropFilter', `blur(${config.backgroundBlur}px)`) 73 | .and('have.css', 'zIndex', config.zIndex.toString()) 74 | .within(() => { 75 | cy.get('svg') 76 | .invoke('css', ['stroke', 'fill']) 77 | .then(({ stroke, fill }) => { 78 | const configColor = tinycolor(config.foregroundColor) 79 | 80 | const cssFill = tinycolor(fill as unknown as string) 81 | const cssStroke = tinycolor(stroke as unknown as string) 82 | 83 | // Maybe there's a more coincise way to do this? 84 | shouldPass = 85 | tinycolor.equals(configColor, cssFill) || tinycolor.equals(cssStroke, configColor) 86 | 87 | if (!shouldPass) throw new Error('Computed SVG styles do not match') 88 | }) 89 | 90 | cy.get('div:not([aria-live])') 91 | .should('have.css', 'opacity', config.backgroundOpacity.toString()) 92 | .invoke('css', 'backgroundColor') 93 | .then((backgroundColor) => { 94 | const configColor = tinycolor(config.backgroundColor) 95 | const cssColor = tinycolor(backgroundColor as unknown as string) 96 | 97 | expect(tinycolor.equals(configColor, cssColor)).to.be.true 98 | }) 99 | }) 100 | 101 | return cy.wrap(subject) 102 | }) 103 | 104 | Cypress.Commands.add('checkDomAttrs', (state: 'displayed' | 'destroyed') => { 105 | if (state === 'displayed') { 106 | cy.get('body') 107 | .should('have.attr', 'aria-hidden', 'true') 108 | .and('have.css', 'pointerEvents', 'none') 109 | 110 | cy.get('html').should('have.css', 'overflow', 'hidden') 111 | } else { 112 | cy.get('body') 113 | .should('not.have.attr', 'aria-hidden', 'true') 114 | .and('not.have.css', 'pointerEvents', 'none') 115 | 116 | cy.get('html').should('not.have.css', 'overflow', 'hidden') 117 | } 118 | }) 119 | 120 | Cypress.Commands.add('triggerAppEvent', (eventName: string) => { 121 | cy.get('body').trigger(eventName, { force: true }) 122 | }) 123 | -------------------------------------------------------------------------------- /tests/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue Global Loader Component Testing 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import './commands' 4 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-global-loader-tests", 3 | "private": true, 4 | "scripts": { 5 | "test": "cypress run --component --browser chrome", 6 | "test:gui": "cypress open --component --browser chrome" 7 | }, 8 | "dependencies": { 9 | "tinycolor2": "^1.6.0", 10 | "vue-global-loader": "workspace:*", 11 | "vue-router": "^4.3.2" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^20.12.8", 15 | "@types/tinycolor2": "^1.4.6", 16 | "@vitejs/plugin-vue": "^5.0.4", 17 | "@vitejs/plugin-vue-jsx": "^3.1.0", 18 | "cypress": "^13.8.1", 19 | "typescript": "^5.4.5", 20 | "vite": "^5.2.11", 21 | "vue": "^3.4.26", 22 | "vue-tsc": "^2.0.16" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/specs/callbacks.cy.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, onMounted, ref } from 'vue' 2 | import { useGlobalLoader } from 'vue-global-loader' 3 | 4 | import GlobalLoader from 'vue-global-loader/GlobalLoader.vue' 5 | import CircleSpinner from 'vue-global-loader/CircleSpinner.vue' 6 | 7 | describe('Callbacks', () => { 8 | describe('onDestroyed', () => { 9 | const destroyedText = 'Destroyed' 10 | 11 | const App = defineComponent({ 12 | setup() { 13 | const { displayLoader, destroyLoader } = useGlobalLoader() 14 | const { displayLoader: displayLoader2, destroyLoader: destroyLoader2 } = 15 | useGlobalLoader() 16 | 17 | const result = ref('') 18 | 19 | onMounted(() => { 20 | window.addEventListener('display-loader', () => displayLoader()) 21 | window.addEventListener('destroy-loader', () => 22 | destroyLoader(() => { 23 | result.value = destroyedText 24 | }) 25 | ) 26 | 27 | window.addEventListener('display-loader-2', () => { 28 | displayLoader2() 29 | result.value = '' 30 | }) 31 | window.addEventListener('destroy-loader-2', () => destroyLoader2()) 32 | }) 33 | 34 | return () => ( 35 | <> 36 |
{result.value}
37 | 38 | 39 | 40 | 41 | ) 42 | }, 43 | }) 44 | 45 | function testOnDestroyed() { 46 | cy.triggerAppEvent('display-loader') 47 | cy.getRoot().should('exist') 48 | 49 | cy.triggerAppEvent('destroy-loader') 50 | cy.getRoot().should('not.exist') 51 | 52 | cy.get('[data-cy-callback]').should('contain.text', destroyedText) 53 | } 54 | 55 | it('onDestroyed callback is called', () => { 56 | cy.mountApp(App) 57 | testOnDestroyed() 58 | }) 59 | 60 | it('onDestroyed callback is called if transition is disabled', () => { 61 | cy.mountApp(App, { transitionDuration: 0 }) 62 | testOnDestroyed() 63 | }) 64 | 65 | it('Previous onDestroyed callback is not available in a new context', () => { 66 | cy.mountApp(App) 67 | 68 | cy.triggerAppEvent('display-loader') 69 | cy.getRoot().should('exist') 70 | 71 | cy.triggerAppEvent('destroy-loader') 72 | cy.getRoot().should('not.exist') 73 | 74 | cy.triggerAppEvent('display-loader-2') 75 | cy.getRoot().should('exist') 76 | 77 | cy.triggerAppEvent('destroy-loader-2') 78 | cy.getRoot().should('not.exist') 79 | 80 | cy.get('[data-cy-callback]').should('not.contain.text', destroyedText) 81 | }) 82 | }) 83 | 84 | describe('onDisplayed', () => { 85 | const App = defineComponent({ 86 | setup() { 87 | const { displayLoader } = useGlobalLoader() 88 | 89 | const result = ref(0) 90 | 91 | onMounted(() => { 92 | window.addEventListener('display-loader', async () => { 93 | const now = Date.now() 94 | await displayLoader() 95 | result.value = Date.now() - now 96 | }) 97 | }) 98 | 99 | return () => ( 100 | <> 101 |
{result.value}
102 | 103 | 104 | 105 | 106 | ) 107 | }, 108 | }) 109 | 110 | it('displayLoader returns a promise', () => { 111 | cy.mountApp(App, { transitionDuration: 1000 }) 112 | 113 | cy.triggerAppEvent('display-loader') 114 | cy.get('[data-cy-promise]').should(($div) => { 115 | const duration = parseInt($div.text()) 116 | expect(duration).to.be.greaterThan(1000).and.to.be.approximately(1000, 100) 117 | }) 118 | }) 119 | 120 | it('displayLoader resolves if transition is disabled', () => { 121 | cy.mountApp(App, { transitionDuration: 0 }) 122 | 123 | cy.triggerAppEvent('display-loader') 124 | cy.get('[data-cy-promise]').should(($div) => { 125 | const duration = parseInt($div.text()) 126 | expect(duration).to.be.greaterThan(0).to.be.approximately(0, 100) 127 | }) 128 | }) 129 | }) 130 | }) 131 | -------------------------------------------------------------------------------- /tests/specs/config.cy.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent as c, onMounted } from 'vue' 2 | import { useGlobalLoader, DEFAULT_OPTIONS as DEF } from 'vue-global-loader' 3 | 4 | import GlobalLoader from 'vue-global-loader/GlobalLoader.vue' 5 | import CircleSpinner from 'vue-global-loader/CircleSpinner.vue' 6 | 7 | describe('Config', () => { 8 | const App = c({ 9 | setup() { 10 | const { displayLoader } = useGlobalLoader() 11 | onMounted(displayLoader) 12 | 13 | return () => ( 14 | 15 | 16 | 17 | ) 18 | }, 19 | }) 20 | 21 | it('Default config is injected', () => { 22 | cy.mountApp(App) 23 | .getRoot() 24 | .checkCssVars(DEF) 25 | .checkComputedStyles(DEF) 26 | 27 | .get('[aria-live]') 28 | .should('contain.text', DEF.screenReaderMessage) 29 | }) 30 | 31 | it('Custom config is injected', () => { 32 | const customConf = { 33 | backgroundColor: 'red', 34 | backgroundOpacity: 0.5, 35 | backgroundBlur: 10, 36 | foregroundColor: 'blue', 37 | transitionDuration: 1000, 38 | screenReaderMessage: 'Custom message', 39 | zIndex: 1000, 40 | } 41 | 42 | cy.mountApp(App, customConf) 43 | .getRoot() 44 | .checkCssVars(customConf) 45 | .checkComputedStyles(customConf) 46 | 47 | .get('[aria-live]') 48 | .should('contain.text', customConf.screenReaderMessage) 49 | }) 50 | 51 | it('Custom config overrides and is merged with default config', () => { 52 | const customConf2 = { 53 | backgroundColor: 'red', 54 | backgroundOpacity: 0.5, 55 | backgroundBlur: 10, 56 | } as const 57 | 58 | cy.mountApp(App, customConf2) 59 | .getRoot() 60 | .checkCssVars({ ...DEF, ...customConf2 }) 61 | .checkComputedStyles({ ...DEF, ...customConf2 }) 62 | 63 | .get('[aria-live]') 64 | .should('contain.text', DEF.screenReaderMessage) 65 | }) 66 | 67 | it('Config can be updated via `updateOptions`', () => { 68 | const customConf = { 69 | backgroundColor: 'orange', 70 | backgroundOpacity: 0.75, 71 | backgroundBlur: 5, 72 | screenReaderMessage: 'Custom updated message', 73 | } 74 | 75 | const App = c({ 76 | setup() { 77 | const { displayLoader, updateOptions } = useGlobalLoader() 78 | 79 | onMounted(() => { 80 | window.addEventListener('display-loader', () => displayLoader()) 81 | window.addEventListener('update-options', () => updateOptions(customConf)) 82 | }) 83 | 84 | return () => ( 85 | 86 | 87 | 88 | ) 89 | }, 90 | }) 91 | 92 | cy.mountApp(App) 93 | .triggerAppEvent('display-loader') 94 | 95 | .getRoot() 96 | .checkCssVars(DEF) 97 | .checkComputedStyles(DEF) 98 | .get('[aria-live]') 99 | .should('contain.text', DEF.screenReaderMessage) 100 | 101 | .triggerAppEvent('update-options') 102 | 103 | .getRoot() 104 | .checkCssVars({ ...DEF, ...customConf }) 105 | .checkComputedStyles({ ...DEF, ...customConf }) 106 | .get('[aria-live]') 107 | .should('contain.text', customConf.screenReaderMessage) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /tests/specs/dom.cy.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent as c, onMounted, ref } from 'vue' 2 | import { RouterView, useRouter } from 'vue-router' 3 | import { useGlobalLoader } from 'vue-global-loader' 4 | 5 | import GlobalLoader from 'vue-global-loader/GlobalLoader.vue' 6 | import CircleSpinner from 'vue-global-loader/CircleSpinner.vue' 7 | 8 | describe('DOM Mutations', () => { 9 | const App = c({ 10 | setup() { 11 | const { displayLoader, destroyLoader } = useGlobalLoader() 12 | const isMounted = ref(true) 13 | 14 | onMounted(() => { 15 | window.addEventListener('display-loader', () => displayLoader()) 16 | window.addEventListener('destroy-loader', () => destroyLoader()) 17 | window.addEventListener('destroy-global-loader', () => (isMounted.value = false)) 18 | }) 19 | 20 | return () => 21 | isMounted.value && ( 22 | 23 | 24 | 25 | ) 26 | }, 27 | }) 28 | 29 | it('Teleports to HTML', () => { 30 | cy.mountApp(App) 31 | 32 | .get('body') 33 | .triggerAppEvent('display-loader') 34 | 35 | cy.get('body').siblings('[data-cy-loader]').should('exist') 36 | }) 37 | 38 | function checkDom() { 39 | for (let i = 0; i < 20; i++) { 40 | cy.triggerAppEvent('display-loader') 41 | cy.getRoot().should('exist') 42 | cy.checkDomAttrs('displayed') 43 | 44 | cy.triggerAppEvent('destroy-loader') 45 | cy.getRoot().should('not.exist') 46 | cy.checkDomAttrs('destroyed') 47 | } 48 | } 49 | 50 | it('DOM mutations are toggled properly', () => { 51 | cy.mountApp(App) 52 | 53 | checkDom() 54 | }) 55 | 56 | it('DOM mutations are toggled properly if transition is disabled', () => { 57 | cy.mountApp(App, { transitionDuration: 0 }) 58 | 59 | checkDom() 60 | }) 61 | 62 | it('DOM mutations are restored if GlobalLoader is removed from the DOM', () => { 63 | cy.mountApp(App) 64 | 65 | cy.triggerAppEvent('display-loader') 66 | cy.getRoot().should('exist') 67 | cy.checkDomAttrs('displayed') 68 | 69 | cy.triggerAppEvent('destroy-global-loader') 70 | cy.getRoot().should('not.exist') 71 | cy.checkDomAttrs('destroyed') 72 | }) 73 | }) 74 | 75 | describe('Focus', () => { 76 | const App = c({ 77 | setup() { 78 | const router = useRouter() 79 | const { displayLoader, destroyLoader } = useGlobalLoader() 80 | 81 | onMounted(() => { 82 | window.addEventListener('display-loader', () => displayLoader()) 83 | window.addEventListener('destroy-loader', () => destroyLoader()) 84 | window.addEventListener('go-to-about', () => router.push('/about')) 85 | }) 86 | 87 | return () => ( 88 | <> 89 | 90 | 91 | 92 | 93 | 94 | ) 95 | }, 96 | }) 97 | 98 | const Home = c({ 99 | setup() { 100 | return () => ( 101 | <> 102 |

Home

103 | 104 | 105 | ) 106 | }, 107 | }) 108 | 109 | const About = c({ 110 | setup() { 111 | return () =>

About

112 | }, 113 | }) 114 | 115 | it('Focuses back to prev focused element', () => { 116 | cy.mountApp(App, {}, [ 117 | { 118 | path: '/', 119 | component: Home, 120 | }, 121 | ]) 122 | .get('[data-cy-submit]') 123 | .focus() 124 | 125 | cy.focused().should('exist').and('have.attr', 'data-cy-submit') 126 | 127 | cy.triggerAppEvent('display-loader') 128 | 129 | cy.focused().should('not.exist') 130 | 131 | cy.triggerAppEvent('destroy-loader') 132 | 133 | cy.focused().should('exist').and('have.attr', 'data-cy-submit') 134 | }) 135 | 136 | it("Doesn't throw if after navigation, prev focused element is not available", () => { 137 | cy.mountApp(App, {}, [ 138 | { path: '/', component: Home }, 139 | { path: '/about', component: About }, 140 | ]) 141 | .get('[data-cy-submit]') 142 | .focus() 143 | 144 | cy.focused().should('exist').and('have.attr', 'data-cy-submit') 145 | 146 | cy.triggerAppEvent('display-loader') 147 | cy.triggerAppEvent('go-to-about') 148 | cy.triggerAppEvent('destroy-loader') 149 | 150 | cy.get('h1').should('contain.text', 'About') 151 | 152 | cy.focused().should('not.exist') 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /tests/specs/router.cy.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent as c, onMounted } from 'vue' 2 | import { RouterView, useRouter } from 'vue-router' 3 | import { useGlobalLoader } from 'vue-global-loader' 4 | 5 | import GlobalLoader from 'vue-global-loader/GlobalLoader.vue' 6 | import CircleSpinner from 'vue-global-loader/CircleSpinner.vue' 7 | 8 | describe('Router', () => { 9 | const NAVIGATION_DELAY = 2000 10 | 11 | const App = c({ 12 | setup() { 13 | return () => ( 14 | <> 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | }, 22 | }) 23 | 24 | const Home = c({ 25 | setup() { 26 | const { displayLoader } = useGlobalLoader() 27 | const onDisplay = () => displayLoader() 28 | 29 | const router = useRouter() 30 | 31 | onMounted(() => { 32 | window.addEventListener('display-loader', onDisplay) 33 | 34 | setTimeout(() => { 35 | router.push('/about') 36 | }, NAVIGATION_DELAY) 37 | }) 38 | 39 | return () =>

Home

40 | }, 41 | }) 42 | 43 | const About = c({ 44 | setup() { 45 | const { destroyLoader } = useGlobalLoader() 46 | const onDestroy = () => destroyLoader() 47 | 48 | onMounted(() => window.addEventListener('destroy-loader', onDestroy)) 49 | 50 | return () =>

About

51 | }, 52 | }) 53 | 54 | it('Loader persists and can be destroyed after navigation', () => { 55 | cy.mountApp(App, {}, [ 56 | { path: '/', component: Home }, 57 | { path: '/about', component: About }, 58 | ]) 59 | 60 | cy.triggerAppEvent('display-loader') 61 | 62 | cy.wait(NAVIGATION_DELAY) 63 | 64 | cy.get('h1') 65 | .should('contain.text', 'About') 66 | .getRoot() 67 | .should('be.visible') 68 | .triggerAppEvent('destroy-loader') 69 | 70 | cy.getRoot().should('not.exist') 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /tests/specs/scoped-options.cy.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, onMounted } from 'vue' 2 | import { useGlobalLoader, DEFAULT_OPTIONS as DEF } from 'vue-global-loader' 3 | 4 | import GlobalLoader from 'vue-global-loader/GlobalLoader.vue' 5 | import CircleSpinner from 'vue-global-loader/CircleSpinner.vue' 6 | 7 | describe('Scoped Options', () => { 8 | const customConf = { 9 | backgroundColor: 'red', 10 | backgroundOpacity: 0.5, 11 | backgroundBlur: 10, 12 | foregroundColor: 'blue', 13 | transitionDuration: 1000, 14 | screenReaderMessage: 'Custom message', 15 | zIndex: 1000, 16 | } 17 | 18 | const App = defineComponent({ 19 | setup() { 20 | const { displayLoader, destroyLoader } = useGlobalLoader(customConf) 21 | const { displayLoader: displayLoader2 } = useGlobalLoader() 22 | 23 | onMounted(() => { 24 | window.addEventListener('display-loader', () => displayLoader()) 25 | window.addEventListener('destroy-loader', () => destroyLoader()) 26 | window.addEventListener('display-loader-2', () => displayLoader2()) 27 | }) 28 | 29 | return () => ( 30 | 31 | 32 | 33 | ) 34 | }, 35 | }) 36 | 37 | it('Scoped options are applied to a specific loader and restored on destroy', () => { 38 | cy.mountApp(App) 39 | 40 | cy.triggerAppEvent('display-loader') 41 | 42 | cy.getRoot() 43 | .checkCssVars(customConf) 44 | .checkComputedStyles(customConf) 45 | .within(() => { 46 | cy.get('[aria-live]').should('contain.text', customConf.screenReaderMessage) 47 | }) 48 | 49 | cy.triggerAppEvent('destroy-loader') 50 | 51 | cy.getRoot().should('not.exist') 52 | cy.triggerAppEvent('display-loader-2') 53 | cy.getRoot() 54 | .checkCssVars(DEF) 55 | .checkComputedStyles(DEF) 56 | .within(() => { 57 | cy.get('[aria-live]').should('contain.text', DEF.screenReaderMessage) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /tests/specs/spinners.cy.tsx: -------------------------------------------------------------------------------- 1 | import { defineComponent, onMounted } from 'vue' 2 | import { useGlobalLoader } from 'vue-global-loader' 3 | 4 | import GlobalLoader from 'vue-global-loader/GlobalLoader.vue' 5 | import CircleSpinner from 'vue-global-loader/CircleSpinner.vue' 6 | import RingSpinner from 'vue-global-loader/RingSpinner.vue' 7 | import RingDotSpinner from 'vue-global-loader/RingDotSpinner.vue' 8 | import RingBarsSpinner from 'vue-global-loader/RingBarsSpinner.vue' 9 | import PulseSpinner from 'vue-global-loader/PulseSpinner.vue' 10 | import BarsSpinner from 'vue-global-loader/BarsSpinner.vue' 11 | import DotsSpinner from 'vue-global-loader/DotsSpinner.vue' 12 | import WaveSpinner from 'vue-global-loader/WaveSpinner.vue' 13 | 14 | describe('Spinners', () => { 15 | it('All spinners are rendered', () => { 16 | ;[ 17 | CircleSpinner, 18 | RingSpinner, 19 | RingDotSpinner, 20 | RingBarsSpinner, 21 | PulseSpinner, 22 | BarsSpinner, 23 | DotsSpinner, 24 | WaveSpinner, 25 | ].forEach((Spinner) => { 26 | const app = defineComponent({ 27 | setup() { 28 | const { displayLoader } = useGlobalLoader() 29 | onMounted(displayLoader) 30 | 31 | return () => ( 32 | 33 | 34 | 35 | ) 36 | }, 37 | }) 38 | 39 | cy.mountApp(app) 40 | .getRoot() 41 | .within(() => { 42 | cy.get('svg').should('exist') 43 | }) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /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 | "skipLibCheck": true, 12 | "jsx": "preserve", 13 | "jsxImportSource": "vue", 14 | "noEmit": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "@/support/*": ["./cypress/support/*"], 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["."] 22 | } 23 | --------------------------------------------------------------------------------