├── .browserslistrc ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .stylelintignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── commitlint.config.js ├── docs ├── app.vue ├── components │ └── demo-block.vue ├── index.html ├── index.ts └── shims.d.ts ├── jest.config.js ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── rollup.config.js ├── src ├── README.md ├── index.ts └── shims.d.ts ├── stylelint.config.js ├── tests ├── events.spec.ts ├── methods.spec.ts └── props.spec.ts ├── tsconfig.eslint.json ├── tsconfig.json └── webpack.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | not ie 11 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.local* 2 | coverage 3 | dist 4 | node_modules 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-typescript/base', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:vue/vue3-recommended', 11 | ], 12 | parser: 'vue-eslint-parser', 13 | parserOptions: { 14 | parser: '@typescript-eslint/parser', 15 | project: 'tsconfig.eslint.json', 16 | sourceType: 'module', 17 | extraFileExtensions: ['.vue'], 18 | }, 19 | plugins: [ 20 | '@typescript-eslint', 21 | 'import', 22 | 'vue', 23 | ], 24 | rules: { 25 | '@typescript-eslint/no-explicit-any': 'off', 26 | '@typescript-eslint/no-var-requires': 'off', 27 | 'no-restricted-properties': 'off', 28 | }, 29 | overrides: [ 30 | { 31 | files: ['tests/**/*.ts'], 32 | env: { 33 | jest: true, 34 | }, 35 | }, 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 18 18 | - run: npm install 19 | - run: npm run lint 20 | - run: npm run build 21 | - run: npm test 22 | - uses: codecov/codecov-action@v5 23 | env: 24 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | tags: 6 | - v2.* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 18 18 | - run: npm install 19 | - run: npm run build:docs 20 | - run: | 21 | cd docs/dist 22 | git init 23 | git config user.name "${{ github.actor }}" 24 | git config user.email "${{ github.actor }}@users.noreply.github.com" 25 | git add --all 26 | git commit --message "♥" 27 | git push --force https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git master:gh-pages 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.local* 2 | *.log 3 | *.map 4 | .DS_Store 5 | coverage 6 | dist 7 | node_modules 8 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | *.local* 2 | coverage 3 | dist 4 | node_modules 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.1.3](https://github.com/fengyuanchen/vue-countdown/compare/v2.1.2...v2.1.3) (2025-03-01) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * improve visibility change event handler in case of the countdown is paused ([6ced876](https://github.com/fengyuanchen/vue-countdown/commit/6ced876555b471bfb3ff90107debc6d52eaabdfa)), closes [#77](https://github.com/fengyuanchen/vue-countdown/issues/77) 7 | 8 | 9 | 10 | ## [2.1.2](https://github.com/fengyuanchen/vue-countdown/compare/v2.1.1...v2.1.2) (2023-08-26) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * reset the end time on start when the `autoStart` prop is set to `false` ([10aef5f](https://github.com/fengyuanchen/vue-countdown/commit/10aef5fc7ce88731291d35ccbb4add2acd8f905a)), closes [#93](https://github.com/fengyuanchen/vue-countdown/issues/93) 16 | 17 | 18 | 19 | ## [2.1.1](https://github.com/fengyuanchen/vue-countdown/compare/v2.1.0...v2.1.1) (2023-03-18) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * update the total milliseconds correctly ([88db379](https://github.com/fengyuanchen/vue-countdown/commit/88db37963bced3e9beaf07f541beff330105a6d9)), closes [#43](https://github.com/fengyuanchen/vue-countdown/issues/43) 25 | 26 | 27 | 28 | # [2.1.0](https://github.com/fengyuanchen/vue-countdown/compare/v2.0.0...v2.1.0) (2022-08-13) 29 | 30 | 31 | ### Features 32 | 33 | * add `restart` method ([d4ad1da](https://github.com/fengyuanchen/vue-countdown/commit/d4ad1dae77b38b3d09913ac00ea8d3ec5c2f2fa3)) 34 | 35 | 36 | 37 | # [2.0.0](https://github.com/fengyuanchen/vue-countdown/compare/v2.0.0-rc...v2.0.0) (2022-02-07) 38 | 39 | 40 | 41 | # [2.0.0-rc](https://github.com/fengyuanchen/vue-countdown/compare/v2.0.0-beta...v2.0.0-rc) (2021-06-12) 42 | 43 | 44 | 45 | # [2.0.0-beta](https://github.com/fengyuanchen/vue-countdown/compare/v2.0.0-alpha...v2.0.0-beta) (2021-02-21) 46 | 47 | 48 | 49 | # [2.0.0-alpha](https://github.com/fengyuanchen/vue-countdown/compare/v1.1.5...v2.0.0-alpha) (2021-02-11) 50 | 51 | 52 | * refactor!: upgrade to Vue 3 ([c998a67](https://github.com/fengyuanchen/vue-countdown/commit/c998a67e5c6b454803510ef1cbb4d180072124f9)) 53 | 54 | 55 | ### BREAKING CHANGES 56 | 57 | * drop support for Vue 2. 58 | 59 | 60 | 61 | ## [1.1.5](https://github.com/fengyuanchen/vue-countdown/compare/v1.1.4...v1.1.5) (2020-02-25) 62 | 63 | 64 | 65 | ## [1.1.4](https://github.com/fengyuanchen/vue-countdown/compare/v1.1.3...v1.1.4) (2019-12-21) 66 | 67 | 68 | ### Bug Fixes 69 | 70 | * avoid losing time for each progress ([0350c13](https://github.com/fengyuanchen/vue-countdown/commit/0350c13e05a33b57f032838e5fe67a8de44ba282)), closes [#43](https://github.com/fengyuanchen/vue-countdown/issues/43) 71 | 72 | 73 | 74 | ## [1.1.3](https://github.com/fengyuanchen/vue-countdown/compare/v1.1.2...v1.1.3) (2019-09-14) 75 | 76 | 77 | ### Bug Fixes 78 | 79 | * continue counting down only when the current tab is visible ([bb5e6ba](https://github.com/fengyuanchen/vue-countdown/commit/bb5e6ba9d1bccf0a392b158d9483451efa8220da)), closes [#37](https://github.com/fengyuanchen/vue-countdown/issues/37) 80 | 81 | 82 | 83 | ## [1.1.2](https://github.com/fengyuanchen/vue-countdown/compare/v1.1.1...v1.1.2) (2019-04-16) 84 | 85 | 86 | ### Bug Fixes 87 | 88 | * add missing properties for the progress event ([96b065a](https://github.com/fengyuanchen/vue-countdown/commit/96b065aefea6bca0ad736eac365679ae42482004)), closes [#34](https://github.com/fengyuanchen/vue-countdown/issues/34) 89 | 90 | 91 | 92 | ## [1.1.1](https://github.com/fengyuanchen/vue-countdown/compare/v1.1.0...v1.1.1) (2019-04-05) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * improve browser compatibility ([f144fe2](https://github.com/fengyuanchen/vue-countdown/commit/f144fe2e72a0f7fd4269fa6f0dc386198658be03)) 98 | * replace setTimeout with requestAnimationFrame ([#33](https://github.com/fengyuanchen/vue-countdown/issues/33)) ([5f1d632](https://github.com/fengyuanchen/vue-countdown/commit/5f1d632449dd975511eb57a528e00f995d913c44)) 99 | 100 | 101 | 102 | # [1.1.0](https://github.com/fengyuanchen/vue-countdown/compare/v1.0.1...v1.1.0) (2018-12-23) 103 | 104 | 105 | ### Features 106 | 107 | * add `now` option ([f321039](https://github.com/fengyuanchen/vue-countdown/commit/f321039afff73f2463584ba3cdaf222c8465aaba)) 108 | 109 | 110 | ### Reverts 111 | 112 | * restore build script ([6703bb2](https://github.com/fengyuanchen/vue-countdown/commit/6703bb24954e7eda7e6a0db5c62893a5a983c2f1)) 113 | 114 | 115 | 116 | ## [1.0.1](https://github.com/fengyuanchen/vue-countdown/compare/v1.0.0...v1.0.1) (2018-11-09) 117 | 118 | 119 | ### Bug Fixes 120 | 121 | * start immediately when mounted ([40fb7f5](https://github.com/fengyuanchen/vue-countdown/commit/40fb7f5be1c986d4f5beadd2555f0620e56e7410)), closes [#1](https://github.com/fengyuanchen/vue-countdown/issues/1) 122 | 123 | 124 | 125 | # 1.0.0 (2018-10-21) 126 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2018-present Chen Fengyuan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-countdown 2 | 3 | [![Coverage Status](https://img.shields.io/codecov/c/github/fengyuanchen/vue-countdown.svg)](https://codecov.io/gh/fengyuanchen/vue-countdown) [![Downloads](https://img.shields.io/npm/dm/@chenfengyuan/vue-countdown.svg)](https://www.npmjs.com/package/@chenfengyuan/vue-countdown) [![Version](https://img.shields.io/npm/v/@chenfengyuan/vue-countdown.svg)](https://www.npmjs.com/package/@chenfengyuan/vue-countdown) [![Gzip Size](https://img.shields.io/bundlephobia/minzip/@chenfengyuan/vue-countdown.svg)](https://unpkg.com/@chenfengyuan/vue-countdown/dist/vue-countdown.js) 4 | 5 | > Countdown component for Vue 3. For Vue 2, check out the [`v1`](https://github.com/fengyuanchen/vue-countdown/tree/v1) branch. 6 | 7 | - [Docs](src/README.md) 8 | - [Demo](https://fengyuanchen.github.io/vue-countdown) 9 | 10 | ## Main npm package files 11 | 12 | ```text 13 | dist/ 14 | ├── vue-countdown.js (UMD, default) 15 | ├── vue-countdown.min.js (UMD, compressed) 16 | ├── vue-countdown.esm.js (ECMAScript Module) 17 | ├── vue-countdown.esm.min.js (ECMAScript Module, compressed) 18 | └── vue-countdown.d.ts (TypeScript Declaration File) 19 | ``` 20 | 21 | ## Getting started 22 | 23 | ### Installation 24 | 25 | Using npm: 26 | 27 | ```shell 28 | npm install vue@3 @chenfengyuan/vue-countdown@2 29 | ``` 30 | 31 | Using pnpm: 32 | 33 | ```shell 34 | pnpm add vue@3 @chenfengyuan/vue-countdown@2 35 | ``` 36 | 37 | Using Yarn: 38 | 39 | ```shell 40 | yarn add vue@3 @chenfengyuan/vue-countdown@2 41 | ``` 42 | 43 | Using CDN: 44 | 45 | ```html 46 | 47 | 48 | ``` 49 | 50 | ### Usage 51 | 52 | ```js 53 | import { createApp } from 'vue'; 54 | import VueCountdown from '@chenfengyuan/vue-countdown'; 55 | 56 | const app = createApp({}); 57 | 58 | app.component(VueCountdown.name, VueCountdown); 59 | ``` 60 | 61 | ```html 62 | 63 | Time Remaining:{{ days }} days, {{ hours }} hours, {{ minutes }} minutes, {{ seconds }} seconds. 64 | 65 | 66 | ``` 67 | 68 | ## Browser support 69 | 70 | Same as Vue 3. 71 | 72 | ## Versioning 73 | 74 | Maintained under the [Semantic Versioning guidelines](https://semver.org/). 75 | 76 | ## License 77 | 78 | [MIT](https://opensource.org/licenses/MIT) © [Chen Fengyuan](https://chenfengyuan.com/) 79 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env'], 3 | }; 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /docs/app.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 143 | 144 | 236 | -------------------------------------------------------------------------------- /docs/components/demo-block.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | vue-countdown 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './app.vue'; 3 | import DemoBlock from './components/demo-block.vue'; 4 | import VueCountdown from '../src'; 5 | 6 | const app = createApp(App); 7 | 8 | app.component(VueCountdown.name, VueCountdown); 9 | app.component(DemoBlock.name, DemoBlock); 10 | app.mount('#app'); 11 | -------------------------------------------------------------------------------- /docs/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | const content: any; 3 | 4 | export default content; 5 | } 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | collectCoverage: true, 4 | coverageDirectory: 'coverage', 5 | coverageReporters: ['html', 'lcov', 'text'], 6 | moduleFileExtensions: ['js', 'ts', 'vue'], 7 | transform: { 8 | '^.+\\.js$': 'babel-jest', 9 | '^.+\\.vue$': '@vue/vue3-jest', 10 | }, 11 | testEnvironment: 'jsdom', 12 | testEnvironmentOptions: { 13 | customExportConditions: [ 14 | 'node', 15 | 'node-addons', 16 | ], 17 | }, 18 | testMatch: ['**/tests/*.spec.ts'], 19 | }; 20 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.{js,ts,vue}': 'eslint --fix', 3 | '*.{css,scss,vue}': 'stylelint --fix', 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@chenfengyuan/vue-countdown", 3 | "version": "2.1.3", 4 | "description": "Countdown component for Vue 3.", 5 | "main": "dist/vue-countdown.js", 6 | "module": "dist/vue-countdown.esm.js", 7 | "types": "dist/vue-countdown.d.ts", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "rollup -c --environment BUILD:production", 13 | "build:docs": "webpack --env production", 14 | "build:types": "move-file dist/index.d.ts dist/vue-countdown.d.ts", 15 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", 16 | "clean": "del-cli dist", 17 | "lint": "npm run lint:js && npm run lint:css", 18 | "lint:css": "stylelint **/*.{css,scss,vue} --fix", 19 | "lint:js": "eslint . --ext .js,.ts,.vue --fix", 20 | "prepare": "husky install", 21 | "release": "npm run clean && npm run lint && npm run build && npm run build:types && npm run build:docs && npm test && npm run changelog", 22 | "serve": "webpack serve --hot --open", 23 | "start": "npm run serve", 24 | "test": "jest" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/fengyuanchen/vue-countdown.git" 29 | }, 30 | "keywords": [ 31 | "countdown", 32 | "vue", 33 | "vue3", 34 | "vue-component", 35 | "front-end", 36 | "web" 37 | ], 38 | "author": "Chen Fengyuan (https://chenfengyuan.com/)", 39 | "license": "MIT", 40 | "bugs": "https://github.com/fengyuanchen/vue-countdown/issues", 41 | "homepage": "https://fengyuanchen.github.io/vue-countdown", 42 | "devDependencies": { 43 | "@babel/core": "^7.26.9", 44 | "@babel/preset-env": "^7.26.9", 45 | "@commitlint/cli": "^17.8.1", 46 | "@commitlint/config-conventional": "^17.8.1", 47 | "@types/jest": "^28.1.8", 48 | "@typescript-eslint/eslint-plugin": "^5.62.0", 49 | "@typescript-eslint/parser": "^5.62.0", 50 | "@vue/compiler-sfc": "^3.5.13", 51 | "@vue/test-utils": "^2.4.6", 52 | "@vue/vue3-jest": "^28.1.0", 53 | "babel-jest": "^28.1.3", 54 | "babel-loader": "^8.4.1", 55 | "change-case": "^4.1.2", 56 | "conventional-changelog-cli": "^2.2.2", 57 | "create-banner": "^2.0.0", 58 | "css-loader": "^6.11.0", 59 | "del-cli": "^5.1.0", 60 | "eslint": "^8.48.0", 61 | "eslint-config-airbnb-typescript": "^17.1.0", 62 | "eslint-plugin-import": "^2.31.0", 63 | "eslint-plugin-vue": "^9.32.0", 64 | "html-webpack-plugin": "^5.6.3", 65 | "husky": "^8.0.3", 66 | "jest": "^28.1.3", 67 | "jest-environment-jsdom": "^28.1.3", 68 | "lint-staged": "^13.3.0", 69 | "markdown-to-vue-loader": "^3.1.5", 70 | "mini-css-extract-plugin": "^2.9.2", 71 | "move-file-cli": "^3.0.0", 72 | "postcss": "^8.5.3", 73 | "rollup": "^2.79.2", 74 | "rollup-plugin-postcss": "^4.0.2", 75 | "rollup-plugin-terser": "^7.0.2", 76 | "rollup-plugin-typescript2": "^0.36.0", 77 | "rollup-plugin-vue": "^6.0.0", 78 | "sass": "^1.85.1", 79 | "sass-loader": "^13.3.3", 80 | "style-loader": "^3.3.4", 81 | "stylelint": "^14.16.1", 82 | "stylelint-config-recommended-scss": "^7.0.0", 83 | "stylelint-config-recommended-vue": "^1.6.0", 84 | "stylelint-order": "^5.0.0", 85 | "ts-jest": "^28.0.8", 86 | "ts-loader": "^9.5.2", 87 | "tslib": "^2.8.1", 88 | "typescript": "^4.9.5", 89 | "vue": "^3.5.13", 90 | "vue-loader": "^17.4.2", 91 | "webpack": "^5.98.0", 92 | "webpack-cli": "^4.10.0", 93 | "webpack-dev-server": "^4.15.2" 94 | }, 95 | "peerDependencies": { 96 | "vue": "^3.0.0" 97 | }, 98 | "publishConfig": { 99 | "access": "public" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | stylelint: { 4 | fix: true, 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import createBanner from 'create-banner'; 2 | import postcss from 'rollup-plugin-postcss'; 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import vue from 'rollup-plugin-vue'; 5 | import { pascalCase } from 'change-case'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import pkg from './package.json'; 8 | 9 | const name = pascalCase(pkg.name.replace(/^.+\//, '')); 10 | const banner = createBanner({ 11 | data: { 12 | year: '2018-present', 13 | }, 14 | template: 'inline', 15 | }); 16 | 17 | export default ['umd', 'esm'].map((format) => ({ 18 | input: 'src/index.ts', 19 | output: ['development', 'production'].map((mode) => { 20 | const output = { 21 | banner, 22 | format, 23 | name, 24 | file: pkg.main, 25 | globals: { 26 | vue: 'Vue', 27 | }, 28 | }; 29 | 30 | if (format === 'esm') { 31 | output.file = pkg.module; 32 | } 33 | 34 | if (mode === 'production') { 35 | output.compact = true; 36 | output.file = output.file.replace(/(\.js)$/, '.min$1'); 37 | output.plugins = [ 38 | terser(), 39 | ]; 40 | } 41 | 42 | return output; 43 | }), 44 | external: Object.keys(pkg.peerDependencies), 45 | plugins: [ 46 | typescript({ 47 | tsconfigOverride: { 48 | compilerOptions: { 49 | declaration: format === 'esm', 50 | }, 51 | exclude: [ 52 | 'docs', 53 | 'tests', 54 | ], 55 | }, 56 | }), 57 | vue({ 58 | preprocessStyles: true, 59 | }), 60 | postcss({ 61 | extensions: ['.css', '.scss'], 62 | minimize: true, 63 | }), 64 | ], 65 | })); 66 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Countdown 2 | 3 | > Countdown with optional controls. 4 | 5 | ## Basic usage 6 | 7 | ```html 8 | 13 | ``` 14 | 15 | ## Custom interval 16 | 17 | ```html 18 | 23 | 24 | 36 | ``` 37 | 38 | ## Transform slot props 39 | 40 | ```html 41 | 46 | 47 | 62 | ``` 63 | 64 | ## Countdown on demand 65 | 66 | ```html 67 | 73 | 74 | 91 | ``` 92 | 93 | ## Props 94 | 95 | | Name | Type | Default | Options | Description | 96 | | --- | --- | --- | --- | --- | 97 | | auto-start | `boolean` | `true` | - | Indicate if starts the countdown automatically when initialized or not. | 98 | | emit-events | `boolean` | `true` | - | Indicate if emits the countdown events or not. | 99 | | interval | `number` | `1000` | - | The interval time (in milliseconds) of the countdown progress. The value should not be less than `0`. | 100 | | now | `Function` | `() => Date.now()` | - | Generates the current time (in milliseconds) in a specific time zone. | 101 | | tag | `string` | `"span"` | - | The tag name of the component's root element. | 102 | | time | `number` | `0` | - | The time (in milliseconds) to count down from. | 103 | | transform | `Function` | `(slotProps) => slotProps` | - | Transforms the output slot props before render. The `slotProps` object contains the following properties: `days`, `hours`, `minutes`, `seconds`, `milliseconds`, `totalDays`, `totalHours`, `totalMinutes`, `totalSeconds`, and `totalMilliseconds`. | 104 | 105 | ## Methods 106 | 107 | | Name | Parameters | Description | 108 | | --- | --- | --- | 109 | | start | `()` | Starts the countdown. Run automatically when the `auto-start` prop is set to `true`. | 110 | | abort | `()` | Aborts the countdown immediately. | 111 | | end | `()` | Ends the countdown manually. | 112 | | restart | `()` | Restarts the countdown. | 113 | 114 | ## Events 115 | 116 | | Name | Parameters | Description | 117 | | --- | --- | --- | 118 | | start | `()` | Fires immediately when the countdown starts. | 119 | | progress | `(data)` | Fires continually when the countdown is in progress. The `data` object contains the following properties: `days`, `hours`, `minutes`, `seconds`, `milliseconds`, `totalDays`, `totalHours`, `totalMinutes`, `totalSeconds`, and `totalMilliseconds`. | 120 | | abort | `()` | Fired when the countdown has aborted. | 121 | | end | `()` | Fired when the countdown has ended. | 122 | 123 | > Native events that bubble up from child elements are also available. 124 | 125 | **Note:** You can set the `emit-events` property to `false` to disable these events for better performance. 126 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineComponent, 3 | h, 4 | } from 'vue'; 5 | 6 | const MILLISECONDS_SECOND = 1000; 7 | const MILLISECONDS_MINUTE = 60 * MILLISECONDS_SECOND; 8 | const MILLISECONDS_HOUR = 60 * MILLISECONDS_MINUTE; 9 | const MILLISECONDS_DAY = 24 * MILLISECONDS_HOUR; 10 | const EVENT_ABORT = 'abort'; 11 | const EVENT_END = 'end'; 12 | const EVENT_PROGRESS = 'progress'; 13 | const EVENT_START = 'start'; 14 | const EVENT_VISIBILITY_CHANGE = 'visibilitychange'; 15 | 16 | export default defineComponent({ 17 | name: 'VueCountdown', 18 | 19 | props: { 20 | /** 21 | * Starts the countdown automatically when initialized. 22 | */ 23 | autoStart: { 24 | type: Boolean, 25 | default: true, 26 | }, 27 | 28 | /** 29 | * Emits the countdown events. 30 | */ 31 | emitEvents: { 32 | type: Boolean, 33 | default: true, 34 | }, 35 | 36 | /** 37 | * The interval time (in milliseconds) of the countdown progress. 38 | */ 39 | interval: { 40 | type: Number, 41 | default: 1000, 42 | validator: (value: number) => value >= 0, 43 | }, 44 | 45 | /** 46 | * Generate the current time of a specific time zone. 47 | */ 48 | now: { 49 | type: Function, 50 | default: () => Date.now(), 51 | }, 52 | 53 | /** 54 | * The tag name of the component's root element. 55 | */ 56 | tag: { 57 | type: String, 58 | default: 'span', 59 | }, 60 | 61 | /** 62 | * The time (in milliseconds) to count down from. 63 | */ 64 | time: { 65 | type: Number, 66 | default: 0, 67 | validator: (value: number) => value >= 0, 68 | }, 69 | 70 | /** 71 | * Transforms the output props before rendering. 72 | */ 73 | transform: { 74 | type: Function, 75 | default: (props: unknown) => props, 76 | }, 77 | }, 78 | 79 | emits: [ 80 | EVENT_ABORT, 81 | EVENT_END, 82 | EVENT_PROGRESS, 83 | EVENT_START, 84 | ], 85 | 86 | data() { 87 | return { 88 | /** 89 | * It is counting down. 90 | * @type {boolean} 91 | */ 92 | counting: false, 93 | 94 | /** 95 | * The absolute end time. 96 | * @type {number} 97 | */ 98 | endTime: 0, 99 | 100 | /** 101 | * The remaining milliseconds. 102 | * @type {number} 103 | */ 104 | totalMilliseconds: 0, 105 | 106 | /** 107 | * The request ID of the requestAnimationFrame. 108 | * @type {number} 109 | */ 110 | requestId: 0, 111 | 112 | /** 113 | * Automatically pause the countdown when the document is hidden. 114 | * @type {boolean} 115 | */ 116 | autoPauseOnHide: false, 117 | }; 118 | }, 119 | 120 | computed: { 121 | /** 122 | * Remaining days. 123 | * @returns {number} The computed value. 124 | */ 125 | days(): number { 126 | return Math.floor(this.totalMilliseconds / MILLISECONDS_DAY); 127 | }, 128 | 129 | /** 130 | * Remaining hours. 131 | * @returns {number} The computed value. 132 | */ 133 | hours(): number { 134 | return Math.floor((this.totalMilliseconds % MILLISECONDS_DAY) / MILLISECONDS_HOUR); 135 | }, 136 | 137 | /** 138 | * Remaining minutes. 139 | * @returns {number} The computed value. 140 | */ 141 | minutes(): number { 142 | return Math.floor((this.totalMilliseconds % MILLISECONDS_HOUR) / MILLISECONDS_MINUTE); 143 | }, 144 | 145 | /** 146 | * Remaining seconds. 147 | * @returns {number} The computed value. 148 | */ 149 | seconds(): number { 150 | return Math.floor((this.totalMilliseconds % MILLISECONDS_MINUTE) / MILLISECONDS_SECOND); 151 | }, 152 | 153 | /** 154 | * Remaining milliseconds. 155 | * @returns {number} The computed value. 156 | */ 157 | milliseconds(): number { 158 | return Math.floor(this.totalMilliseconds % MILLISECONDS_SECOND); 159 | }, 160 | 161 | /** 162 | * Total remaining days. 163 | * @returns {number} The computed value. 164 | */ 165 | totalDays(): number { 166 | return this.days; 167 | }, 168 | 169 | /** 170 | * Total remaining hours. 171 | * @returns {number} The computed value. 172 | */ 173 | totalHours(): number { 174 | return Math.floor(this.totalMilliseconds / MILLISECONDS_HOUR); 175 | }, 176 | 177 | /** 178 | * Total remaining minutes. 179 | * @returns {number} The computed value. 180 | */ 181 | totalMinutes(): number { 182 | return Math.floor(this.totalMilliseconds / MILLISECONDS_MINUTE); 183 | }, 184 | 185 | /** 186 | * Total remaining seconds. 187 | * @returns {number} The computed value. 188 | */ 189 | totalSeconds(): number { 190 | return Math.floor(this.totalMilliseconds / MILLISECONDS_SECOND); 191 | }, 192 | }, 193 | 194 | watch: { 195 | $props: { 196 | deep: true, 197 | immediate: true, 198 | 199 | /** 200 | * Update the countdown when props changed. 201 | */ 202 | handler() { 203 | this.totalMilliseconds = this.time; 204 | this.endTime = this.now() + this.time; 205 | 206 | if (this.autoStart) { 207 | this.start(); 208 | } 209 | }, 210 | }, 211 | }, 212 | 213 | mounted() { 214 | document.addEventListener(EVENT_VISIBILITY_CHANGE, this.handleVisibilityChange); 215 | }, 216 | 217 | beforeUnmount() { 218 | document.removeEventListener(EVENT_VISIBILITY_CHANGE, this.handleVisibilityChange); 219 | this.pause(); 220 | }, 221 | 222 | methods: { 223 | /** 224 | * Starts to countdown. 225 | * @public 226 | * @emits Countdown#start 227 | */ 228 | start() { 229 | if (this.counting) { 230 | return; 231 | } 232 | 233 | this.counting = true; 234 | 235 | if (!this.autoStart) { 236 | this.totalMilliseconds = this.time; 237 | this.endTime = this.now() + this.time; 238 | } 239 | 240 | if (this.emitEvents) { 241 | /** 242 | * Countdown start event. 243 | * @event Countdown#start 244 | */ 245 | this.$emit(EVENT_START); 246 | } 247 | 248 | if (document.visibilityState === 'visible') { 249 | this.continue(); 250 | } 251 | }, 252 | 253 | /** 254 | * Continues the countdown. 255 | * @private 256 | */ 257 | continue() { 258 | if (!this.counting) { 259 | return; 260 | } 261 | 262 | const delay = Math.min(this.totalMilliseconds, this.interval); 263 | 264 | if (delay > 0) { 265 | let init: number; 266 | let prev: number; 267 | const step = (now: number) => { 268 | if (!init) { 269 | init = now; 270 | } 271 | 272 | if (!prev) { 273 | prev = now; 274 | } 275 | 276 | const range = now - init; 277 | 278 | if ( 279 | range >= delay 280 | 281 | // Avoid losing time about one second per minute (now - prev ≈ 16ms) (#43) 282 | || range + ((now - prev) / 2) >= delay 283 | ) { 284 | this.progress(); 285 | } else { 286 | this.requestId = requestAnimationFrame(step); 287 | } 288 | 289 | prev = now; 290 | }; 291 | 292 | this.requestId = requestAnimationFrame(step); 293 | } else { 294 | this.end(); 295 | } 296 | }, 297 | 298 | /** 299 | * Pauses the countdown. 300 | * @private 301 | */ 302 | pause() { 303 | cancelAnimationFrame(this.requestId); 304 | this.requestId = 0; 305 | }, 306 | 307 | /** 308 | * Progresses to countdown. 309 | * @private 310 | * @emits Countdown#progress 311 | */ 312 | progress() { 313 | if (!this.counting) { 314 | return; 315 | } 316 | 317 | this.update(); 318 | 319 | if (this.emitEvents && this.totalMilliseconds > 0) { 320 | /** 321 | * Countdown progress event. 322 | * @event Countdown#progress 323 | */ 324 | this.$emit(EVENT_PROGRESS, { 325 | days: this.days, 326 | hours: this.hours, 327 | minutes: this.minutes, 328 | seconds: this.seconds, 329 | milliseconds: this.milliseconds, 330 | totalDays: this.totalDays, 331 | totalHours: this.totalHours, 332 | totalMinutes: this.totalMinutes, 333 | totalSeconds: this.totalSeconds, 334 | totalMilliseconds: this.totalMilliseconds, 335 | }); 336 | } 337 | 338 | this.continue(); 339 | }, 340 | 341 | /** 342 | * Aborts the countdown. 343 | * @public 344 | * @emits Countdown#abort 345 | */ 346 | abort() { 347 | if (!this.counting) { 348 | return; 349 | } 350 | 351 | this.pause(); 352 | this.counting = false; 353 | 354 | if (this.emitEvents) { 355 | /** 356 | * Countdown abort event. 357 | * @event Countdown#abort 358 | */ 359 | this.$emit(EVENT_ABORT); 360 | } 361 | }, 362 | 363 | /** 364 | * Ends the countdown. 365 | * @public 366 | * @emits Countdown#end 367 | */ 368 | end() { 369 | if (!this.counting) { 370 | return; 371 | } 372 | 373 | this.pause(); 374 | this.totalMilliseconds = 0; 375 | this.counting = false; 376 | 377 | if (this.emitEvents) { 378 | /** 379 | * Countdown end event. 380 | * @event Countdown#end 381 | */ 382 | this.$emit(EVENT_END); 383 | } 384 | }, 385 | 386 | /** 387 | * Updates the count. 388 | * @private 389 | */ 390 | update() { 391 | if (this.counting) { 392 | this.totalMilliseconds = Math.max(0, this.endTime - this.now()); 393 | } 394 | }, 395 | 396 | /** 397 | * Restarts the count. 398 | * @public 399 | */ 400 | restart() { 401 | this.pause(); 402 | this.totalMilliseconds = this.time; 403 | this.endTime = this.now() + this.time; 404 | this.counting = false; 405 | this.start(); 406 | }, 407 | 408 | /** 409 | * visibility change event handler. 410 | * @private 411 | */ 412 | handleVisibilityChange() { 413 | switch (document.visibilityState) { 414 | case 'visible': 415 | if (this.requestId === 0 && this.autoPauseOnHide) { 416 | this.update(); 417 | this.continue(); 418 | } 419 | 420 | this.autoPauseOnHide = false; 421 | break; 422 | 423 | case 'hidden': 424 | if (this.requestId > 0) { 425 | this.autoPauseOnHide = true; 426 | this.pause(); 427 | } 428 | break; 429 | 430 | default: 431 | } 432 | }, 433 | }, 434 | 435 | render() { 436 | return h(this.tag, this.$slots.default ? [ 437 | this.$slots.default(this.transform({ 438 | days: this.days, 439 | hours: this.hours, 440 | minutes: this.minutes, 441 | seconds: this.seconds, 442 | milliseconds: this.milliseconds, 443 | totalDays: this.totalDays, 444 | totalHours: this.totalHours, 445 | totalMinutes: this.totalMinutes, 446 | totalSeconds: this.totalSeconds, 447 | totalMilliseconds: this.totalMilliseconds, 448 | })), 449 | ] : undefined); 450 | }, 451 | }); 452 | -------------------------------------------------------------------------------- /src/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const content: any; 3 | 4 | export default content; 5 | } 6 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'stylelint-config-recommended-vue/scss', 3 | plugins: [ 4 | 'stylelint-order', 5 | ], 6 | rules: { 7 | 'no-descending-specificity': null, 8 | 'no-empty-source': null, 9 | 'order/properties-alphabetical-order': true, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /tests/events.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import VueCountdown from '../src'; 3 | 4 | describe('events', () => { 5 | describe('start', () => { 6 | it('should trigger the `start` event', (done) => { 7 | mount({ 8 | components: { 9 | VueCountdown, 10 | }, 11 | methods: { 12 | onCountdownStart() { 13 | done(); 14 | }, 15 | }, 16 | template: '', 17 | }); 18 | }); 19 | 20 | it('should not trigger the `start` event when the `emit-events` property is set to `false`', (done) => { 21 | mount({ 22 | components: { 23 | VueCountdown, 24 | }, 25 | methods: { 26 | onCountdownStart() { 27 | throw new Error(); 28 | }, 29 | }, 30 | template: '', 31 | }); 32 | setTimeout(done, 1000); 33 | }); 34 | }); 35 | 36 | describe('progress', () => { 37 | it('should trigger the `progress` event', (done) => { 38 | mount({ 39 | components: { 40 | VueCountdown, 41 | }, 42 | methods: { 43 | onCountdownProgress() { 44 | done(); 45 | }, 46 | }, 47 | template: '', 48 | }); 49 | }); 50 | 51 | it('should not trigger the `progress` event when the `emit-events` property is set to `false`', (done) => { 52 | mount({ 53 | components: { 54 | VueCountdown, 55 | }, 56 | methods: { 57 | onCountdownProgress() { 58 | throw new Error(); 59 | }, 60 | }, 61 | template: '', 62 | }); 63 | setTimeout(done, 1000); 64 | }); 65 | 66 | it('should not trigger the `progress` event when the time is less then the interval', (done) => { 67 | mount({ 68 | components: { 69 | VueCountdown, 70 | }, 71 | mounted() { 72 | setTimeout(done, 200); 73 | }, 74 | methods: { 75 | handleCountdownProgress() { 76 | throw new Error(); 77 | }, 78 | }, 79 | template: '', 80 | }); 81 | }); 82 | }); 83 | 84 | describe('abort', () => { 85 | it('should trigger the `abort` event', (done) => { 86 | mount({ 87 | components: { 88 | VueCountdown, 89 | }, 90 | mounted() { 91 | setTimeout(() => { 92 | (this as any).$refs.countdown.abort(); 93 | }, 500); 94 | }, 95 | methods: { 96 | onCountdownAbort() { 97 | done(); 98 | }, 99 | }, 100 | template: '', 101 | }); 102 | }); 103 | 104 | it('should not trigger the `abort` event when the `emit-events` property is set to `false`', (done) => { 105 | mount({ 106 | components: { 107 | VueCountdown, 108 | }, 109 | mounted() { 110 | setTimeout(() => { 111 | (this as any).$refs.countdown.abort(); 112 | setTimeout(done, 500); 113 | }, 500); 114 | }, 115 | methods: { 116 | onCountdownAbort() { 117 | throw new Error(); 118 | }, 119 | }, 120 | template: '', 121 | }); 122 | }); 123 | }); 124 | 125 | describe('end', () => { 126 | it('should trigger the `end` event', (done) => { 127 | mount({ 128 | components: { 129 | VueCountdown, 130 | }, 131 | methods: { 132 | onCountdownEnd() { 133 | done(); 134 | }, 135 | }, 136 | template: '', 137 | }); 138 | }); 139 | 140 | it('should not trigger the `end` event when the `emit-events` property is set to `false`', (done) => { 141 | mount({ 142 | components: { 143 | VueCountdown, 144 | }, 145 | methods: { 146 | onCountdownEnd() { 147 | throw new Error(); 148 | }, 149 | }, 150 | template: '', 151 | }); 152 | setTimeout(done, 1000); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /tests/methods.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import VueCountdown from '../src'; 3 | 4 | describe('methods', () => { 5 | describe('start', () => { 6 | it('should start the countdown', () => { 7 | const wrapper = mount(VueCountdown, { 8 | props: { 9 | autoStart: false, 10 | time: 5000, 11 | }, 12 | }); 13 | 14 | expect(wrapper.vm.counting).toBe(false); 15 | wrapper.vm.start(); 16 | expect(wrapper.vm.counting).toBe(true); 17 | }); 18 | 19 | it('should do nothing when it has been started', () => { 20 | const wrapper = mount(VueCountdown, { 21 | props: { 22 | time: 5000, 23 | }, 24 | }); 25 | 26 | expect(wrapper.vm.counting).toBe(true); 27 | wrapper.vm.start(); 28 | }); 29 | }); 30 | 31 | describe('abort', () => { 32 | it('should abort the countdown', () => { 33 | const wrapper = mount(VueCountdown, { 34 | props: { 35 | time: 5000, 36 | }, 37 | }); 38 | 39 | expect(wrapper.vm.counting).toBe(true); 40 | wrapper.vm.abort(); 41 | expect(wrapper.vm.counting).toBe(false); 42 | expect(wrapper.vm.totalMilliseconds).toBeGreaterThan(0); 43 | }); 44 | 45 | it('should do nothing when it is not started', () => { 46 | const wrapper = mount(VueCountdown, { 47 | props: { 48 | autoStart: false, 49 | time: 5000, 50 | }, 51 | }); 52 | 53 | expect(wrapper.vm.counting).toBe(false); 54 | wrapper.vm.abort(); 55 | }); 56 | }); 57 | 58 | describe('end', () => { 59 | it('should end the countdown', () => { 60 | const wrapper = mount(VueCountdown, { 61 | props: { 62 | time: 5000, 63 | }, 64 | }); 65 | 66 | expect(wrapper.vm.counting).toBe(true); 67 | wrapper.vm.end(); 68 | expect(wrapper.vm.counting).toBe(false); 69 | expect(wrapper.vm.totalMilliseconds).toBe(0); 70 | }); 71 | 72 | it('should do nothing when it is not started', () => { 73 | const wrapper = mount(VueCountdown, { 74 | props: { 75 | autoStart: false, 76 | time: 5000, 77 | }, 78 | }); 79 | 80 | expect(wrapper.vm.counting).toBe(false); 81 | wrapper.vm.end(); 82 | }); 83 | }); 84 | 85 | describe('restart', () => { 86 | it('should restart the countdown', (done) => { 87 | const wrapper = mount(VueCountdown, { 88 | props: { 89 | time: 5000, 90 | }, 91 | }); 92 | 93 | setTimeout(() => { 94 | expect(wrapper.vm.totalMilliseconds).toBeLessThan(5000); 95 | wrapper.vm.restart(); 96 | expect(wrapper.vm.totalMilliseconds).toBe(5000); 97 | done(); 98 | }, 1500); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /tests/props.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils'; 2 | import VueCountdown from '../src'; 3 | 4 | describe('props', () => { 5 | describe('auto-start', () => { 6 | it('should be `true` by default', () => { 7 | const wrapper = mount(VueCountdown, { 8 | props: { 9 | time: 5000, 10 | }, 11 | }); 12 | 13 | expect(wrapper.props('autoStart')).toBe(true); 14 | expect(wrapper.vm.counting).toBe(true); 15 | }); 16 | 17 | it('should be `false`', () => { 18 | const wrapper = mount(VueCountdown, { 19 | props: { 20 | autoStart: false, 21 | time: 5000, 22 | }, 23 | }); 24 | 25 | expect(wrapper.props('autoStart')).toBe(false); 26 | expect(wrapper.vm.counting).toBe(false); 27 | }); 28 | 29 | it('should start the countdown when set to `true` manually', (done) => { 30 | mount({ 31 | components: { 32 | VueCountdown, 33 | }, 34 | data() { 35 | return { 36 | autoStart: false, 37 | }; 38 | }, 39 | mounted() { 40 | setTimeout(() => { 41 | (this as any).autoStart = true; 42 | }, 100); 43 | }, 44 | methods: { 45 | onCountdownStart() { 46 | done(); 47 | }, 48 | }, 49 | template: '', 50 | }); 51 | }); 52 | }); 53 | 54 | describe('emit-events', () => { 55 | it('should be `true` by default', (done) => { 56 | mount({ 57 | components: { 58 | VueCountdown, 59 | }, 60 | methods: { 61 | onCountdownStart() { 62 | done(); 63 | }, 64 | }, 65 | template: '', 66 | }); 67 | }); 68 | 69 | it('should be `false`', () => { 70 | mount({ 71 | components: { 72 | VueCountdown, 73 | }, 74 | methods: { 75 | onCountdownStart() { 76 | throw new Error(); 77 | }, 78 | }, 79 | template: '', 80 | }); 81 | }); 82 | }); 83 | 84 | describe('interval', () => { 85 | it('should be `1000` by default', () => { 86 | const wrapper = mount(VueCountdown, { 87 | props: { 88 | time: 5000, 89 | }, 90 | }); 91 | 92 | expect(wrapper.props('interval')).toBe(1000); 93 | }); 94 | 95 | it('should be `100`', () => { 96 | const wrapper = mount(VueCountdown, { 97 | props: { 98 | interval: 100, 99 | time: 5000, 100 | }, 101 | }); 102 | 103 | expect(wrapper.props('interval')).toBe(100); 104 | }); 105 | 106 | it('should use the minimum value of `time` and `interval` as interval', (done) => { 107 | mount({ 108 | components: { 109 | VueCountdown, 110 | }, 111 | data() { 112 | return { 113 | startTime: 0, 114 | }; 115 | }, 116 | methods: { 117 | onCountdownStart() { 118 | (this as any).startTime = Date.now(); 119 | }, 120 | onCountdownEnd() { 121 | const interval = Date.now() - (this as any).startTime; 122 | 123 | expect(interval).toBeLessThan(200); 124 | done(); 125 | }, 126 | }, 127 | template: '', 128 | }); 129 | }); 130 | }); 131 | 132 | describe('now', () => { 133 | it('should be now by default', () => { 134 | const wrapper = mount(VueCountdown, { 135 | props: { 136 | time: 5000, 137 | }, 138 | }); 139 | 140 | expect(wrapper.vm.now()).toBeLessThanOrEqual(Date.now()); 141 | }); 142 | 143 | it('should match the given time', () => { 144 | const date = new Date(); 145 | const wrapper = mount(VueCountdown, { 146 | props: { 147 | now: () => new Date(Date.UTC( 148 | date.getFullYear(), 149 | date.getMonth(), 150 | date.getDate(), 151 | )).getTime(), 152 | time: 5000, 153 | }, 154 | }); 155 | 156 | expect(wrapper.vm.now()).toBeGreaterThanOrEqual(new Date( 157 | date.getFullYear(), 158 | date.getMonth(), 159 | date.getDate(), 160 | ).getTime()); 161 | }); 162 | }); 163 | 164 | describe('tag', () => { 165 | it('should be "span" by default', () => { 166 | const wrapper = mount(VueCountdown); 167 | 168 | expect(wrapper.props('tag')).toBe('span'); 169 | expect(wrapper.vm.$el.tagName.toLowerCase()).toBe('span'); 170 | }); 171 | 172 | it('should be "div" by default', () => { 173 | const wrapper = mount(VueCountdown, { 174 | props: { 175 | tag: 'div', 176 | }, 177 | }); 178 | 179 | expect(wrapper.props('tag')).toBe('div'); 180 | expect(wrapper.vm.$el.tagName.toLowerCase()).toBe('div'); 181 | }); 182 | }); 183 | 184 | describe('time', () => { 185 | it('should be `0` by default', () => { 186 | const wrapper = mount(VueCountdown); 187 | 188 | expect(wrapper.props('time')).toBe(0); 189 | }); 190 | 191 | it('should be `1000` by default', () => { 192 | const wrapper = mount(VueCountdown, { 193 | props: { 194 | time: 1000, 195 | }, 196 | }); 197 | 198 | expect(wrapper.props('time')).toBe(1000); 199 | }); 200 | 201 | it('should update the countdown when the `time` prop is changed', (done) => { 202 | mount({ 203 | components: { 204 | VueCountdown, 205 | }, 206 | data() { 207 | return { 208 | time: 1000, 209 | }; 210 | }, 211 | mounted() { 212 | expect((this as any).$refs.countdown.totalMilliseconds).toBe(1000); 213 | (this as any).time = 2000; 214 | (this as any).$nextTick(() => { 215 | expect((this as any).$refs.countdown.totalMilliseconds).toBe(2000); 216 | done(); 217 | }); 218 | }, 219 | template: '', 220 | }); 221 | }); 222 | }); 223 | 224 | describe('transform', () => { 225 | it('should not transform the output props by default', (done) => { 226 | mount({ 227 | components: { 228 | VueCountdown, 229 | }, 230 | mounted() { 231 | expect((this as any).$el.textContent).toBe('0 days, 0 hours, 0 minutes, 2 seconds, 0 milliseconds.'); 232 | done(); 233 | }, 234 | template: '{{ days }} days, {{ hours }} hours, {{ minutes }} minutes, {{ seconds }} seconds, {{ milliseconds }} milliseconds.', 235 | }); 236 | }); 237 | 238 | it('should transform the output props', (done) => { 239 | mount({ 240 | components: { 241 | VueCountdown, 242 | }, 243 | mounted() { 244 | expect((this as any).$el.textContent).toBe('00 days, 00 hours, 00 minutes, 02 seconds, 00 milliseconds.'); 245 | done(); 246 | }, 247 | methods: { 248 | transform(props: Record) { 249 | const formattedProps: Record = {}; 250 | 251 | Object.entries(props).forEach(([key, value]) => { 252 | formattedProps[key] = value < 10 ? `0${value}` : String(value); 253 | }); 254 | 255 | return formattedProps; 256 | }, 257 | }, 258 | template: '{{ days }} days, {{ hours }} hours, {{ minutes }} minutes, {{ seconds }} seconds, {{ milliseconds }} milliseconds.', 259 | }); 260 | }); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": [ 4 | "*.js", 5 | ".*.js", 6 | "docs/**/*", 7 | "src/**/*", 8 | "tests/**/*" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "moduleResolution": "node", 5 | "resolveJsonModule": true, 6 | "strict": true, 7 | "target": "esnext" 8 | }, 9 | "include": [ 10 | "src/**/*", 11 | "docs/**/*", 12 | "tests/**/*" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const VueLoaderPlugin = require('vue-loader/dist/plugin').default; 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | 7 | module.exports = (env) => ({ 8 | mode: env.production ? 'production' : 'development', 9 | entry: './docs', 10 | output: { 11 | path: path.resolve(__dirname, './docs/dist'), 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.js$/, 17 | use: 'babel-loader', 18 | }, 19 | { 20 | test: /\.ts$/, 21 | loader: 'ts-loader', 22 | options: { 23 | appendTsSuffixTo: [/\.vue$/], 24 | }, 25 | }, 26 | { 27 | test: /\.vue$/, 28 | loader: 'vue-loader', 29 | }, 30 | { 31 | test: /\.scss$/, 32 | use: [ 33 | MiniCssExtractPlugin.loader, 34 | 'css-loader', 35 | 'sass-loader', 36 | ], 37 | }, 38 | { 39 | test: /\.md$/, 40 | use: [ 41 | 'vue-loader', 42 | { 43 | loader: 'markdown-to-vue-loader', 44 | options: { 45 | componentWrapper: '', 46 | tableClass: 'table', 47 | tableWrapper: '
', 48 | }, 49 | }, 50 | ], 51 | }, 52 | ], 53 | }, 54 | plugins: [ 55 | new HtmlWebpackPlugin({ 56 | filename: 'index.html', 57 | template: './docs/index.html', 58 | }), 59 | new MiniCssExtractPlugin(), 60 | new VueLoaderPlugin(), 61 | new webpack.DefinePlugin({ 62 | __VUE_OPTIONS_API__: true, 63 | __VUE_PROD_DEVTOOLS__: false, 64 | }), 65 | ], 66 | externals: env.production ? { 67 | vue: 'Vue', 68 | } : {}, 69 | resolve: { 70 | alias: { 71 | vue$: 'vue/dist/vue.esm-bundler', 72 | }, 73 | extensions: ['.js', '.json', '.ts', '.d.ts', '.vue'], 74 | }, 75 | }); 76 | --------------------------------------------------------------------------------