├── .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 | [](https://codecov.io/gh/fengyuanchen/vue-countdown) [](https://www.npmjs.com/package/@chenfengyuan/vue-countdown) [](https://www.npmjs.com/package/@chenfengyuan/vue-countdown) [](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 |
2 |
3 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | {{ name }}
60 | v{{ version }}
61 |
62 |
63 |
64 | {{ description }}
65 |
66 |
67 |
73 |
74 |
75 |
76 |
81 |
82 |
113 |
114 |
115 |
116 |
143 |
144 |
236 |
--------------------------------------------------------------------------------
/docs/components/demo-block.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
9 |
10 | Time Remaining:{{ days }} days, {{ hours }} hours, {{ minutes }} minutes, {{ seconds }} seconds.
11 |
12 |
13 | ```
14 |
15 | ## Custom interval
16 |
17 | ```html
18 |
19 |
20 | New Year Countdown:{{ days }} days, {{ hours }} hours, {{ minutes }} minutes, {{ seconds }}.{{ Math.floor(milliseconds / 100) }} seconds.
21 |
22 |
23 |
24 |
36 | ```
37 |
38 | ## Transform slot props
39 |
40 | ```html
41 |
42 |
43 | Time Remaining:{{ days }} days, {{ hours }} hours, {{ minutes }} minutes, {{ seconds }} seconds.
44 |
45 |
46 |
47 |
62 | ```
63 |
64 | ## Countdown on demand
65 |
66 | ```html
67 |
68 |
72 |
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 |
--------------------------------------------------------------------------------