├── .editorconfig ├── .github └── workflows │ ├── codeql.yml │ └── main.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── config └── setupTests.ts ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ └── index.spec.ts ├── plugins │ └── index.js ├── support │ ├── commands.js │ └── index.js └── tsconfig.json ├── e2e ├── CountdownApi.tsx ├── index.html └── index.tsx ├── examples ├── .env ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── ControlledCountdown.tsx │ ├── CountdownApi.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ └── serviceWorker.ts ├── tsconfig.json └── yarn.lock ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── Countdown.test.tsx ├── Countdown.tsx ├── LegacyCountdown.tsx ├── __snapshots__ │ └── Countdown.test.tsx.snap ├── index.ts ├── utils.test.ts └── utils.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '39 1 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v3 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v2 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ['*'] 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Use Node.js 20.x 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | 22 | - name: Get yarn cache directory path 23 | id: yarn-cache-dir-path 24 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 25 | 26 | - uses: actions/cache@v4 27 | id: yarn-cache 28 | with: 29 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | 34 | - name: Install 35 | run: yarn install 36 | 37 | - name: 'Lint: Prettier' 38 | run: yarn lint:prettier 39 | 40 | - name: 'Lint: TypeScript' 41 | run: yarn lint:tslint && yarn lint:tsc 42 | 43 | test: 44 | name: Test 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Use Node.js 20.x 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: 20 54 | 55 | - name: Get yarn cache directory path 56 | id: yarn-cache-dir-path 57 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 58 | 59 | - uses: actions/cache@v4 60 | id: yarn-cache 61 | with: 62 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 63 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 64 | restore-keys: | 65 | ${{ runner.os }}-yarn- 66 | 67 | - name: Install 68 | run: yarn install 69 | 70 | - name: 'Tests: Unit' 71 | run: yarn test:coverage 72 | 73 | - name: 'Tests: E2E' 74 | run: yarn test:e2e 75 | 76 | - name: Coveralls 77 | uses: coverallsapp/github-action@master 78 | with: 79 | github-token: ${{ secrets.GITHUB_TOKEN }} 80 | 81 | build: 82 | name: Build 83 | needs: [lint, test] 84 | runs-on: ubuntu-latest 85 | 86 | steps: 87 | - uses: actions/checkout@v4 88 | 89 | - name: Use Node.js 20.x 90 | uses: actions/setup-node@v4 91 | with: 92 | node-version: 20 93 | 94 | - name: Get yarn cache directory path 95 | id: yarn-cache-dir-path 96 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 97 | 98 | - uses: actions/cache@v4 99 | id: yarn-cache 100 | with: 101 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 102 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 103 | restore-keys: | 104 | ${{ runner.os }}-yarn- 105 | 106 | - name: Install 107 | run: yarn install 108 | 109 | - name: Build 110 | run: yarn build 111 | 112 | examples: 113 | name: Build examples 114 | needs: build 115 | runs-on: ubuntu-latest 116 | 117 | steps: 118 | - uses: actions/checkout@v4 119 | 120 | - name: Use Node.js 20.x 121 | uses: actions/setup-node@v4 122 | with: 123 | node-version: 20 124 | 125 | - name: Get yarn cache directory path 126 | id: yarn-cache-dir-path 127 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 128 | 129 | - uses: actions/cache@v4 130 | id: yarn-cache 131 | with: 132 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 133 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 134 | restore-keys: | 135 | ${{ runner.os }}-yarn- 136 | 137 | - uses: actions/cache@v4 138 | id: yarn-cache-examples 139 | with: 140 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 141 | key: ${{ runner.os }}-yarn-examples-${{ hashFiles('examples/**/yarn.lock') }} 142 | restore-keys: | 143 | ${{ runner.os }}-yarn-examples- 144 | 145 | - name: Install master 146 | run: yarn install 147 | 148 | - name: Install 149 | run: cd examples && yarn 150 | 151 | - name: Build 152 | run: yarn build 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | .cache/ 5 | *.DS_Store 6 | *.log 7 | *.idea 8 | *.vscode 9 | jsconfig.json 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | config/ 2 | coverage/ 3 | cypress/ 4 | examples/ 5 | node_modules/ 6 | src/ 7 | .cache/ 8 | *.config.js 9 | *.idea 10 | *.lock 11 | *.log 12 | *.test.js 13 | *.test.ts 14 | *.test.d.ts 15 | *.vscode 16 | cypress.json 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to this project! 4 | 5 | Pull requests are very welcome to either fix existing bugs, add new features, or improve the code in general. Before submitting a pull request, please consider filing an issue to discuss the change's details. 6 | 7 | ## Bug Reports 8 | 9 | 1. Please [search for similar issues](https://github.com/ndresx/react-countdown/issues?q=is%3Aissue) before as there is a chance that someone might have reported it already. 10 | 11 | 2. Provide a demo of the bug in isolation if possible (e.g., codesandbox.io); otherwise, try to be as detailed as possible in the description. 12 | 13 | ## Feature Requests 14 | 15 | Feature requests, improvements, and new suggestions are also always welcome, but please take a moment to find out whether your idea fits within the scope of this project or not. 16 | 17 | ## Pull Requests 18 | 19 | 1. [Fork](https://github.com/ndresx/react-countdown/fork) the repository. 20 | 21 | 2. If you are adding new functionality, or fixing a bug, provide tests with code coverage of preferably 100%. 22 | 23 | 3. Push the changes to your fork and [submit a pull request](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork)! 24 | 25 | ## Development 26 | 27 | Once you have forked the project and checked it out locally, create a new branch from `master` and start developing! 28 | 29 | ### Linting 30 | 31 | You can run the linting tools with the following command: 32 | 33 | ```sh 34 | yarn lint 35 | ``` 36 | 37 | ### Tests 38 | 39 | Make sure that all tests are passing and that the code coverage is close to 100%. 40 | 41 | ```sh 42 | yarn test 43 | ``` 44 | 45 | For End-to-End tests, please run the following command: 46 | ```sh 47 | yarn test:e2e 48 | ``` 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Martin Veith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React <Countdown /> [![npm][npm]][npm-url] [![CI: Build Status](https://img.shields.io/github/actions/workflow/status/ndresx/react-countdown/main.yml)](https://github.com/ndresx/react-countdown/actions/workflows/main.yml) [![Coverage Status](https://coveralls.io/repos/github/ndresx/react-countdown/badge.svg?branch=master)](https://coveralls.io/github/ndresx/react-countdown?branch=master) 2 | A customizable countdown component for React. 3 | 4 | * [Getting Started](#getting-started) 5 | * [Motivation](#motivation) 6 | * [Examples](#examples) 7 | * [Props](#props) 8 | * [API Reference](#api-reference) 9 | * [Helpers](#helpers) 10 | * [FAQ](#faq) 11 | * [Contributing](#contributing) 12 | * [License](#license) 13 | 14 | ## Getting Started 15 | 16 | You can install the module via `npm` or `yarn`: 17 | 18 | ```sh 19 | npm install react-countdown --save 20 | ``` 21 | 22 | ```sh 23 | yarn add react-countdown 24 | ``` 25 | 26 | ## Motivation 27 | 28 | As part of a small web app at first, the idea was to separate the countdown component from the main package to combine general aspects of the development with React, testing with Jest and more things that relate to publishing a new Open Source project. 29 | 30 | ## Examples 31 | 32 | Here are some examples which you can try directly online. You can also clone this repo and explore some more examples in there by running `yarn start` within the `examples` folder. 33 | 34 | ### Basic Usage 35 | A very simple and minimal example of how to set up a countdown that counts down from 10 seconds. 36 | ```js 37 | import React from 'react'; 38 | import ReactDOM from 'react-dom'; 39 | import Countdown from 'react-countdown'; 40 | 41 | ReactDOM.render( 42 | , 43 | document.getElementById('root') 44 | ); 45 | ``` 46 | [Live Demo](https://codesandbox.io/s/cool-fermat-uk0dq) 47 | 48 | ### Custom & Conditional Rendering 49 | In case you want to change the output of the component or want to signal that the countdown's work is done, you can do this by either using the [`onComplete`](#oncomplete) callback, a 50 | custom [`renderer`](#renderer), or by specifying a React child within ``, which will only be shown once the countdown is complete. 51 | 52 | #### Using a React Child for the Completed State 53 | 54 | ```js 55 | import React from 'react'; 56 | import ReactDOM from 'react-dom'; 57 | import Countdown from 'react-countdown'; 58 | 59 | // Random component 60 | const Completionist = () => You are good to go!; 61 | 62 | ReactDOM.render( 63 | ( 64 | 65 | 66 | 67 | ), 68 | document.getElementById('root') 69 | ); 70 | ``` 71 | [Live Demo](https://codesandbox.io/s/condescending-bartik-kyp2v) 72 | 73 | #### Custom Renderer with Completed Condition 74 | 75 | ```js 76 | import React from 'react'; 77 | import ReactDOM from 'react-dom'; 78 | import Countdown from 'react-countdown'; 79 | 80 | // Random component 81 | const Completionist = () => You are good to go!; 82 | 83 | // Renderer callback with condition 84 | const renderer = ({ hours, minutes, seconds, completed }) => { 85 | if (completed) { 86 | // Render a completed state 87 | return ; 88 | } else { 89 | // Render a countdown 90 | return {hours}:{minutes}:{seconds}; 91 | } 92 | }; 93 | 94 | ReactDOM.render( 95 | , 99 | document.getElementById('root') 100 | ); 101 | ``` 102 | [Live Demo](https://codesandbox.io/s/sad-zhukovsky-hs7hc) 103 | 104 | ### Countdown in Milliseconds 105 | Here is an example with a countdown of 10 seconds that displays the total time difference in milliseconds. In order to display the milliseconds appropriately, the [`intervalDelay`](#intervaldelay) value needs to be lower than `1000`ms and a [`precision`](#precision) of `1` to `3` should be used. Last but not least, a simple [`renderer`](#renderer) callback needs to be set up. 106 | 107 | ```js 108 | import React from 'react'; 109 | import ReactDOM from 'react-dom'; 110 | import Countdown from 'react-countdown'; 111 | 112 | ReactDOM.render( 113 |
{props.total}
} 118 | />, 119 | document.getElementById('root') 120 | ); 121 | ``` 122 | [Live Demo](https://codesandbox.io/s/elastic-euclid-6vnlw) 123 | 124 | ## Props 125 | 126 | |Name|Type|Default|Description| 127 | |:--|:--:|:-----:|:----------| 128 | |[**date**](#date)|Date|string|number|required|[`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) or timestamp in the future| 129 | |[**key**](#key)|string|number|`undefined`|React [`key`](https://reactjs.org/docs/lists-and-keys.html#keys); can be used to restart the countdown| 130 | |[**daysInHours**](#daysinhours)|`boolean`|`false`|Days are calculated as hours| 131 | |[**zeroPadTime**](#zeropadtime)|`number`|`2`|Length of zero-padded output, e.g.: `00:01:02`| 132 | |[**zeroPadDays**](#zeropaddays)|`number`|`zeroPadTime`|Length of zero-padded days output, e.g.: `01`| 133 | |[**controlled**](#controlled) |`boolean`|`false`|Hands over the control to its parent(s)| 134 | |[**intervalDelay**](#intervaldelay)|`number`|`1000`|Interval delay in milliseconds| 135 | |[**precision**](#precision)|`number`|`0`|The precision on a millisecond basis| 136 | |[**autoStart**](#autostart)|`boolean`|`true`|Countdown auto-start option| 137 | |[**overtime**](#overtime) |`boolean`|`false`|Counts down to infinity| 138 | |[**children**](#children)|`any`|`null`|A React child for the countdown's completed state| 139 | |[**renderer**](#renderer)|`function`|`undefined`|Custom renderer callback| 140 | |[**now**](#now)|`function`|`Date.now`|Alternative handler for the current date| 141 | |[**onMount**](#onmount)|`function`|`undefined`|Callback when component mounts| 142 | |[**onStart**](#onstart)|`function`|`undefined`|Callback when countdown starts| 143 | |[**onPause**](#onpause)|`function`|`undefined`|Callback when countdown pauses| 144 | |[**onStop**](#onstop)|`function`|`undefined`|Callback when countdown stops| 145 | |[**onTick**](#ontick)|`function`|`undefined`|Callback on every interval tick (`controlled` = `false`)| 146 | |[**onComplete**](#oncomplete)|`function`|`undefined`|Callback when countdown ends| 147 | 148 | ### `date` 149 | The `date` prop is the only required one and can be a [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object, `string`, or timestamp in the future. By default, this date is compared with the current date, or a custom handler defined via [`now`](#now). 150 | 151 | Valid values can be _(and more)_: 152 | * `'2020-02-01T01:02:03'` // [`Date` time string format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse#Date_Time_String_Format) 153 | * `1580518923000` // Timestamp in milliseconds 154 | * `new Date(1580518923000)` // [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) object 155 | 156 | ### `key` 157 | This is one of React's internal component props to help identify elements throughout the reconciliation process. It can be used to restart the countdown by 158 | passing in a new `string` or `number` value. 159 | 160 | Please see [official React docs](https://reactjs.org/docs/lists-and-keys.html#keys) for more information about keys. 161 | 162 | ### `daysInHours` 163 | Defines whether the time of day should be calculated as hours rather than separated days. 164 | 165 | ### `controlled` 166 | Can be useful if the countdown's interval and/or date control should be handed over to the parent. In case `controlled` is `true`, the 167 | provided [`date`](#date) will be treated as the countdown's actual time difference and not be compared to [`now`](#now) anymore. 168 | 169 | ### `zeroPadTime` 170 | This option defaults to `2` in order to display the common format `00:00:00` instead of `0:0:0`. If the value is higher than `2`, only the hours part _(see [`zeroPadDays`](#zeropaddays) for days)_ will be zero-padded while it stays at `2` for minutes as well as seconds. If the value is lower, the output won't be zero-padded like the example before is showing. 171 | 172 | ### `zeroPadDays` 173 | Defaults to `zeroPadTime`. It works the same way as [`zeroPadTime`](#zeropadtime) does, just for days. 174 | 175 | ### `intervalDelay` 176 | Since this countdown is based on date comparisons, the default value of `1000` milliseconds is probably enough for most scenarios and doesn't need to be changed. 177 | 178 | However, if it needs to be more precise, the `intervalDelay` can be set to something lower - down to `0`, which would, for example, allow showing the milliseconds in a more fancy way (_currently_ only possible through a custom [`renderer`](#renderer)). 179 | 180 | ### `precision` 181 | In certain cases, you might want to base off the calculations on a millisecond basis. The `precision` prop, which defaults to `0`, can be used to refine this calculation. While the default value simply strips the milliseconds part (e.g., `10123`ms => `10000`ms), a precision of `3` leads to `10123`ms. 182 | 183 | ### `autoStart` 184 | Defines whether the countdown should start automatically or not. Defaults to `true`. 185 | 186 | ### `overtime` 187 | Defines whether the countdown can go into overtime by extending its lifetime past the targeted endpoint. Defaults to `false`. 188 | 189 | When set to `true`, the countdown timer won't stop when hitting 0, but instead becomes negative and continues to run unless paused/stopped. The [`onComplete`](#oncomplete) callback would still get triggered when the initial countdown phase completes. 190 | 191 | > Please note that the [`children`](#children) prop will be ignored if `overtime` is `true`. Also, when using a custom [`renderer`](#renderer), you'll have to check one of the [render props](#render-props), e.g., `total`, or `completed`, to render the overtime output. 192 | 193 | ### `children` 194 | This component also considers the child that may live within the `` element, which, in case it's available, replaces the countdown's component state once it's complete. Moreover, an additional prop called `countdown` is set and contains data similar to what the [`renderer`](#renderer) callback would receive. Here's an [example](#using-a-react-child-for-the-completed-state) that showcases its usage. 195 | 196 | > Please note that the [`children`](#children) prop will be ignored if a custom [`renderer`](#renderer) is defined. 197 | 198 | ### `renderer` 199 | The component's raw render output is kept very simple. 200 | 201 | For more advanced countdown displays, a custom `renderer` callback can be defined to return a new React element. It receives the following [render props](#render-props) as the first argument. 202 | 203 | #### Render Props 204 | 205 | The render props object consists of the current time delta object, the countdown's [`api`](#api-reference), the component [`props`](#props), and last but not least, a [`formatted`](#formattimedelta) object. 206 | 207 | ```js 208 | { 209 | total: 0, 210 | days: 0, 211 | hours: 0, 212 | minutes: 0, 213 | seconds: 0, 214 | milliseconds: 0, 215 | completed: true, 216 | api: { ... }, 217 | props: { ... }, 218 | formatted: { ... } 219 | } 220 | ``` 221 | 222 | > Please note that a defined custom [`renderer`](#renderer) will ignore the [`children`](#children) prop. 223 | 224 | ### `now` 225 | If the current date and time (determined via a reference to `Date.now`) is not the right thing to compare with for you, a reference to a custom function that returns a similar dynamic value could be provided as an alternative. 226 | 227 | ### `onMount` 228 | `onMount` is a callback and triggered when the countdown mounts. It receives a [time delta object](#calctimedelta) as the first argument. 229 | 230 | ### `onStart` 231 | `onStart` is a callback and triggered whenever the countdown is started (including first-run). It receives a [time delta object](#calctimedelta) as the first argument. 232 | 233 | ### `onPause` 234 | `onPause` is a callback and triggered every time the countdown is paused. It receives a [time delta object](#calctimedelta) as the first argument. 235 | 236 | ### `onStop` 237 | `onStop` is a callback and triggered every time the countdown is stopped. It receives a [time delta object](#calctimedelta) as the first argument. 238 | 239 | ### `onTick` 240 | `onTick` is a callback and triggered every time a new period is started, based on what the [`intervalDelay`](#intervaldelay)'s value is. It only gets triggered when the countdown's [`controlled`](#controlled) prop is set to `false`, meaning that the countdown has full control over its interval. It receives a [time delta object](#calctimedelta) as the first argument. 241 | 242 | ### `onComplete` 243 | `onComplete` is a callback and triggered whenever the countdown ends. In contrast to [`onTick`](#ontick), the [`onComplete`](#oncomplete) callback also gets triggered in case [`controlled`](#controlled) is set to `true`. It receives a [time delta object](#calctimedelta) as the first argument and a `boolean` as a second argument, indicating whether the countdown transitioned into the completed state (`false`) or completed on start (`true`). 244 | 245 | ## API Reference 246 | 247 | The countdown component exposes a simple API through the `getApi()` function that can be accessed via component `ref`. It is also part (`api`) of the [render props](#render-props) passed into [`renderer`](#renderer) if needed. Here's an [example](https://github.com/ndresx/react-countdown/blob/master/examples/src/CountdownApi.tsx) of how to use it. 248 | 249 | ### `start()` 250 | Starts the countdown in case it is paused/stopped or needed when [`autoStart`](#autostart) is set to `false`. 251 | 252 | ### `pause()` 253 | Pauses the running countdown. This only works as expected if the [`controlled`](#controlled) prop is set to `false` because [`calcTimeDelta`](#calctimedelta) calculates an offset time internally. 254 | 255 | ### `stop()` 256 | Stops the countdown. This only works as expected if the [`controlled`](#controlled) prop is set to `false` because [`calcTimeDelta`](#calctimedelta) calculates an offset time internally. 257 | 258 | ### `isPaused()` 259 | Returns a `boolean` for whether the countdown has been paused or not. 260 | 261 | ### `isStopped()` 262 | Returns a `boolean` for whether the countdown has been stopped or not. 263 | 264 | ### `isCompleted()` 265 | Returns a `boolean` for whether the countdown has been completed or not. 266 | 267 | > Please note that this will always return `false` if [`overtime`](#overtime) is `true`. Nevertheless, an into overtime running countdown's completed state can still be looking at the time delta object's `completed` value. 268 | 269 | ## Helpers 270 | 271 | This module also exports three simple helper functions, which can be utilized to build your own countdown custom [`renderer`](#renderer). 272 | 273 | ```js 274 | import Countdown, { zeroPad, calcTimeDelta, formatTimeDelta } from 'react-countdown'; 275 | ``` 276 | 277 | ### `zeroPad(value, [length = 2])` 278 | The `zeroPad` function transforms and returns a given `value` with padded zeros depending on the `length`. The `value` can be a `string` or `number`, while the `length` parameter can be a `number`, defaulting to `2`. Returns the zero-padded `string`, e.g., `zeroPad(5)` => `05`. 279 | 280 | ```js 281 | const renderer = ({ hours, minutes, seconds }) => ( 282 | 283 | {zeroPad(hours)}:{zeroPad(minutes)}:{zeroPad(seconds)} 284 | 285 | ); 286 | ``` 287 | 288 | 289 | ### `calcTimeDelta(date, [options])` 290 | `calcTimeDelta` calculates the time difference between a given end [`date`](#date) and the current date (`now`). It returns, similar to the [`renderer`](#renderer) callback, a custom object (also referred to as **countdown time delta object**) with the following time-related data: 291 | 292 | ```js 293 | { total, days, hours, minutes, seconds, milliseconds, completed } 294 | ``` 295 | 296 | The `total` value is the absolute time difference in milliseconds, whereas the other time-related values contain their relative portion of the current time difference. The `completed` value signalizes whether the countdown reached its initial end or not. 297 | 298 | The `calcTimeDelta` function accepts two arguments in total; only the first one is required. 299 | 300 | **`date`** 301 | Date or timestamp representation of the end date. See [`date`](#date) prop for more details. 302 | 303 | **`options`** The second argument consists of the following optional keys. 304 | 305 | - **`now = Date.now`** 306 | Alternative function for returning the current date, also see [`now`](#now). 307 | 308 | - **`precision = 0`** 309 | The [`precision`](#precision) on a millisecond basis. 310 | 311 | - **`controlled = false`** 312 | Defines whether the calculated value is provided in a [`controlled`](#controlled) environment as the time difference or not. 313 | 314 | - **`offsetTime = 0`** 315 | Defines the offset time that gets added to the start time; only considered if controlled is false. 316 | 317 | - **`overtime = false`** 318 | Defines whether the time delta can go into [`overtime`](#overtime) and become negative or not. When set to `true`, the `total` could become negative, at which point `completed` will still be set to `true`. 319 | 320 | 321 | ### `formatTimeDelta(timeDelta, [options])` 322 | `formatTimeDelta` formats a given countdown time delta object. It returns the formatted portion of it, equivalent to: 323 | 324 | ```js 325 | { 326 | days: '00', 327 | hours: '00', 328 | minutes: '00', 329 | seconds: '00', 330 | } 331 | ``` 332 | 333 | This function accepts two arguments in total; only the first one is required. 334 | 335 | **`timeDelta`** 336 | Time delta object, e.g., returned by [`calcTimeDelta`](#calctimedelta). 337 | 338 | **`options`** 339 | The `options` object consists of the following three component props and is used to customize the time delta object's formatting: 340 | * [`daysInHours`](#daysinhours) 341 | * [`zeroPadTime`](#zeropadtime) 342 | * [`zeroPadDays`](#zeropaddays) 343 | 344 | ## FAQ 345 | 346 | ### Why does my countdown reset on every re-render? 347 | 348 | A common reason for this is that the [`date`](#date) prop gets passed directly into the component without persisting it in any way. 349 | 350 | In order to avoid this from happening, it should be stored in a place that persists throughout lifecycle changes, for example, in the component's local `state`. 351 | 352 | 353 | ### Why aren't my values formatted when using the custom [`renderer`](#renderer)? 354 | 355 | The [`renderer`](#renderer) callback gets called with a [time delta object](#calctimedelta) that also consists of a `formatted` object which holds these formatted values. 356 | 357 | ### Why do I get this error `"Warning: Text content did not match..."`? 358 | 359 | This could have something to do with server-side rendering and that the countdown already runs on the server-side, resulting in a timestamp discrepancy between the client and the server. In this case, it might be worth checking https://reactjs.org/docs/dom-elements.html#suppresshydrationwarning. 360 | 361 | Alternatively, you could try to set [`autoStart`](#autostart) to `false` and start the countdown through the [API](#api-reference) once it's available on the client. Here are some related [issues](https://github.com/ndresx/react-countdown/issues/152) that might help in fixing this problem. 362 | 363 | ## Contributing 364 | 365 | Contributions of any kind are very welcome. Read more in our [contributing guide](https://github.com/ndresx/react-countdown/blob/master/CONTRIBUTING.md) about how to report bugs, create pull requests, and other development-related topics. 366 | 367 | ## License 368 | 369 | MIT 370 | 371 | [npm]: https://img.shields.io/npm/v/react-countdown.svg 372 | [npm-url]: https://npmjs.com/package/react-countdown 373 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | api.cache(true); 3 | return { 4 | comments: false, 5 | presets: [ 6 | ['@babel/env', { targets: { browsers: 'last 2 versions' } }], 7 | '@babel/react', 8 | '@babel/typescript', 9 | ], 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /config/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import EnzymeAdapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new EnzymeAdapter() }); 5 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:1234", 3 | "screenshotOnRunFailure": false, 4 | "video": false 5 | } 6 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/index.spec.ts: -------------------------------------------------------------------------------- 1 | describe('', () => { 2 | const ALIAS = 'countdown'; 3 | 4 | const cyGetAs = (selector: string) => cy.get(selector).as(ALIAS); 5 | const cyGet = (alias = ALIAS) => cy.get(`@${alias}`); 6 | 7 | beforeEach(() => { 8 | cy.clock(); 9 | cy.visit('/'); 10 | }); 11 | 12 | describe('Basic Usage', () => { 13 | it('should render final state', () => { 14 | cyGetAs('#basic-usage'); 15 | 16 | for (let i = 5; i > 0; i--) { 17 | cyGet().contains(`00:00:00:0${i}`); 18 | if (i > 0) cy.tick(1000); 19 | } 20 | 21 | cyGet().contains('00:00:00:00'); 22 | }); 23 | 24 | it('should render final state when already in the past', () => { 25 | cyGetAs('#basic-usage-past'); 26 | cyGet().contains('00:00:00:00'); 27 | 28 | cy.tick(1000); 29 | cyGet().contains('00:00:00:00'); 30 | }); 31 | }); 32 | 33 | describe('Custom & Conditional Rendering', () => { 34 | it('should render completed state', () => { 35 | cyGetAs('#children-completionist'); 36 | 37 | for (let i = 5; i > 0; i--) { 38 | cyGet().contains(`00:00:00:0${i}`); 39 | if (i > 0) cy.tick(1000); 40 | } 41 | 42 | cyGet().contains('You are good to go!'); 43 | }); 44 | }); 45 | 46 | describe('Countdown (overtime)', () => { 47 | it('should render infinity', () => { 48 | cyGetAs('#overtime'); 49 | 50 | for (let i = 5; i > -5; i--) { 51 | cyGet().contains(`${i < 0 ? '-' : ''}00:00:00:0${Math.abs(i)}`); 52 | cy.tick(1000); 53 | } 54 | 55 | cy.tick(5000); 56 | cyGet().contains('-00:00:00:10'); 57 | }); 58 | }); 59 | 60 | describe('Countdown API', () => { 61 | beforeEach(() => { 62 | cyGetAs('#api'); 63 | cyGet().contains('00:00:10'); 64 | 65 | cyGet() 66 | .find('button') 67 | .contains('Start') 68 | .as('StartBtn') 69 | .click() 70 | .should('have.be.disabled'); 71 | }); 72 | 73 | it('should click the "Start" button and count down 5s', () => { 74 | cy.tick(5000); 75 | cyGet().contains('00:00:05'); 76 | }); 77 | 78 | it('should click the "Start" (10s) => "Pause" (5s) => "Start" (5s) => "Stop" (3s) buttons => 10s', () => { 79 | cy.tick(5000); 80 | cyGet().contains('00:00:05'); 81 | 82 | cyGet() 83 | .find('button') 84 | .contains('Pause') 85 | .as('PauseBtn') 86 | .click() 87 | .should('have.be.disabled'); 88 | 89 | cy.tick(2000); 90 | cyGet().contains('00:00:05'); 91 | 92 | cyGet('StartBtn').click(); 93 | 94 | cy.tick(2000); 95 | cyGet().contains('00:00:03'); 96 | 97 | cyGet() 98 | .find('button') 99 | .contains('Stop') 100 | .as('StopBtn') 101 | .click() 102 | .should('have.be.disabled'); 103 | 104 | cyGet().contains('00:00:10'); 105 | }); 106 | 107 | it('should reset the countdown at 4s => 10s and count down to 7s', () => { 108 | cy.tick(6000); 109 | cyGet().contains('00:00:04'); 110 | 111 | cyGet() 112 | .find('button') 113 | .contains('Reset') 114 | .as('ResetBtn') 115 | .click(); 116 | 117 | cy.tick(3000); 118 | cyGet().contains('00:00:07'); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": "../node_modules", 5 | "rootDir": "./", 6 | "target": "es5", 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /e2e/CountdownApi.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Countdown, { CountdownRendererFn } from '../dist'; 4 | 5 | interface CountdownApiExampleState { 6 | readonly date: number; 7 | } 8 | 9 | export default class CountdownApiExample extends React.Component<{}, CountdownApiExampleState> { 10 | state = { date: Date.now() + 10000 }; 11 | 12 | handleResetClick = (): void => { 13 | this.setState({ date: Date.now() + 10000 }); 14 | }; 15 | 16 | renderer: CountdownRendererFn = ({ api, formatted }) => { 17 | const { hours, minutes, seconds } = formatted; 18 | const completed = api.isCompleted(); 19 | return ( 20 |
21 | 22 | {hours}:{minutes}:{seconds} 23 | 24 |
25 | {' '} 28 | {' '} 31 | {' '} 34 | 35 |
36 |
37 | ); 38 | }; 39 | 40 | render(): React.ReactNode { 41 | return ; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /e2e/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /e2e/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Countdown, { CountdownRenderProps } from '../dist'; 5 | import CountdownApi from './CountdownApi'; 6 | 7 | // Random component 8 | const Completionist = () => You are good to go!; 9 | 10 | // Renderer callback with condition 11 | const renderer = ({ hours, minutes, seconds, completed }: CountdownRenderProps) => { 12 | if (completed) { 13 | // Render a completed state 14 | return ; 15 | } 16 | 17 | // Render a countdown 18 | return ( 19 | 20 | {hours}:{minutes}:{seconds} 21 | 22 | ); 23 | }; 24 | 25 | class App extends React.Component { 26 | render() { 27 | const date = Date.now() + 5000; 28 | return ( 29 | <> 30 |

React {''} (E2E)

31 |

Basic Usage

32 |

Date in the future

33 |
34 | 35 |
36 |
37 |

Date in the past

38 |
39 | 40 |
41 |
42 |

Custom & Conditional Rendering

43 |

Using a React Child for the Completed State

44 |
45 | 46 | 47 | 48 |
49 |
50 |

51 | Countdown (overtime) 52 |

53 |
54 | 55 |
56 |
57 |

Countdown API

58 |

Countdown with Start, Pause, Stop and Reset Controls (Custom Renderer)

59 |
60 | 61 |
62 | 63 | ); 64 | } 65 | } 66 | 67 | ReactDOM.render(, document.getElementById('root')); 68 | -------------------------------------------------------------------------------- /examples/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.2.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test", 9 | "eject": "react-scripts eject" 10 | }, 11 | "browserslist": [ 12 | ">0.2%", 13 | "not dead", 14 | "not ie <= 11", 15 | "not op_mini all" 16 | ], 17 | "dependencies": { 18 | "@types/react": "^16.8.15", 19 | "@types/react-dom": "16.9.4", 20 | "react": "^16.8.6", 21 | "react-countdown": "^2.2.0", 22 | "react-dom": "^16.8.6", 23 | "react-scripts": "3.3.0", 24 | "typescript": "3.7.4" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ndresx/react-countdown/4e767838df07a91f64608e0fa1bf5fd2c99177c7/examples/public/favicon.ico -------------------------------------------------------------------------------- /examples/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /examples/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/src/ControlledCountdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import Countdown from 'react-countdown'; 4 | 5 | interface ControlledCountdownState { 6 | readonly date: number; 7 | } 8 | 9 | export default class ControlledCountdown extends Component<{}, ControlledCountdownState> { 10 | state = { date: 5000 }; 11 | countdownInterval = 0; 12 | 13 | componentDidMount() { 14 | this.start(); 15 | } 16 | 17 | componentWillUnmount(): void { 18 | this.clearInterval(); 19 | } 20 | 21 | start(): void { 22 | this.countdownInterval = window.setInterval(() => { 23 | if (this.state.date <= 0) { 24 | return this.clearInterval(); 25 | } 26 | 27 | this.setState(({ date }) => ({ date: date - 1000 })); 28 | }, 1000); 29 | } 30 | 31 | clearInterval(): void { 32 | window.clearInterval(this.countdownInterval); 33 | } 34 | 35 | render() { 36 | return ( 37 | <> 38 |

Controlled Countdown

39 | {this.state.date > 0 ? ( 40 | 41 | ) : ( 42 | 'Completed!' 43 | )} 44 | 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/src/CountdownApi.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import Countdown, { CountdownApi } from 'react-countdown'; 4 | 5 | export default class CountdownApiExample extends Component { 6 | countdownApi: CountdownApi | null = null; 7 | state = { date: Date.now() + 10000 }; 8 | 9 | handleStartClick = (): void => { 10 | this.countdownApi && this.countdownApi.start(); 11 | }; 12 | 13 | handlePauseClick = (): void => { 14 | this.countdownApi && this.countdownApi.pause(); 15 | }; 16 | 17 | handleResetClick = (): void => { 18 | this.setState({ date: Date.now() + 10000 }); 19 | }; 20 | 21 | handleUpdate = (): void => { 22 | this.forceUpdate(); 23 | }; 24 | 25 | setRef = (countdown: Countdown | null): void => { 26 | if (countdown) { 27 | this.countdownApi = countdown.getApi(); 28 | } 29 | }; 30 | 31 | isPaused(): boolean { 32 | return !!(this.countdownApi && this.countdownApi.isPaused()); 33 | } 34 | 35 | isCompleted(): boolean { 36 | return !!(this.countdownApi && this.countdownApi.isCompleted()); 37 | } 38 | 39 | render() { 40 | return ( 41 | <> 42 |

Countdown with Start, Pause and Reset Controls

43 | 53 |
54 | {' '} 61 | {' '} 68 | 71 |
72 | 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /examples/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Countdown, { CountdownRenderProps } from 'react-countdown'; 5 | import ControlledCountdown from './ControlledCountdown'; 6 | import CountdownApi from './CountdownApi'; 7 | 8 | // Random component 9 | const Completionist = () => You are good to go!; 10 | 11 | // Renderer callback with condition 12 | const renderer = ({ hours, minutes, seconds, completed }: CountdownRenderProps) => { 13 | if (completed) { 14 | // Render a completed state 15 | return ; 16 | } 17 | 18 | // Render a countdown 19 | return ( 20 | 21 | {hours}:{minutes}:{seconds} 22 | 23 | ); 24 | }; 25 | 26 | class App extends Component { 27 | render() { 28 | return ( 29 | <> 30 |

React <Countdown />

31 |

Examples

32 |

Basic Usage

33 | 34 |
35 |

Custom & Conditional Rendering

36 |

Using a React Child for the Completed State

37 | 38 | 39 | 40 |

Custom Renderer with Completed Condition

41 | 42 |
43 |

Countdown in Milliseconds

44 |
{props.total}
} 49 | /> 50 |
51 | 52 |
53 |

Custom Renderer with Stringified Props

54 |
{JSON.stringify(props, null, 2)}
} 60 | /> 61 |
62 | 63 | 64 | ); 65 | } 66 | } 67 | 68 | ReactDOM.render(, document.getElementById('root')); 69 | -------------------------------------------------------------------------------- /examples/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | type Config = { 22 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 23 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 24 | }; 25 | 26 | export function register(config?: Config) { 27 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 28 | // The URL constructor is available in all browsers that support SW. 29 | const publicUrl = new URL( 30 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 31 | window.location.href 32 | ); 33 | if (publicUrl.origin !== window.location.origin) { 34 | // Our service worker won't work if PUBLIC_URL is on a different origin 35 | // from what our page is served on. This might happen if a CDN is used to 36 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 37 | return; 38 | } 39 | 40 | window.addEventListener('load', () => { 41 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 42 | 43 | if (isLocalhost) { 44 | // This is running on localhost. Let's check if a service worker still exists or not. 45 | checkValidServiceWorker(swUrl, config); 46 | 47 | // Add some additional logging to localhost, pointing developers to the 48 | // service worker/PWA documentation. 49 | navigator.serviceWorker.ready.then(() => { 50 | console.log( 51 | 'This web app is being served cache-first by a service ' + 52 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 53 | ); 54 | }); 55 | } else { 56 | // Is not localhost. Just register service worker 57 | registerValidSW(swUrl, config); 58 | } 59 | }); 60 | } 61 | } 62 | 63 | function registerValidSW(swUrl: string, config?: Config) { 64 | navigator.serviceWorker 65 | .register(swUrl) 66 | .then(registration => { 67 | registration.onupdatefound = () => { 68 | const installingWorker = registration.installing; 69 | if (installingWorker == null) { 70 | return; 71 | } 72 | installingWorker.onstatechange = () => { 73 | if (installingWorker.state === 'installed') { 74 | if (navigator.serviceWorker.controller) { 75 | // At this point, the updated precached content has been fetched, 76 | // but the previous service worker will still serve the older 77 | // content until all client tabs are closed. 78 | console.log( 79 | 'New content is available and will be used when all ' + 80 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 81 | ); 82 | 83 | // Execute callback 84 | if (config && config.onUpdate) { 85 | config.onUpdate(registration); 86 | } 87 | } else { 88 | // At this point, everything has been precached. 89 | // It's the perfect time to display a 90 | // "Content is cached for offline use." message. 91 | console.log('Content is cached for offline use.'); 92 | 93 | // Execute callback 94 | if (config && config.onSuccess) { 95 | config.onSuccess(registration); 96 | } 97 | } 98 | } 99 | }; 100 | }; 101 | }) 102 | .catch(error => { 103 | console.error('Error during service worker registration:', error); 104 | }); 105 | } 106 | 107 | function checkValidServiceWorker(swUrl: string, config?: Config) { 108 | // Check if the service worker can be found. If it can't reload the page. 109 | fetch(swUrl) 110 | .then(response => { 111 | // Ensure service worker exists, and that we really are getting a JS file. 112 | const contentType = response.headers.get('content-type'); 113 | if ( 114 | response.status === 404 || 115 | (contentType != null && contentType.indexOf('javascript') === -1) 116 | ) { 117 | // No service worker found. Probably a different app. Reload the page. 118 | navigator.serviceWorker.ready.then(registration => { 119 | registration.unregister().then(() => { 120 | window.location.reload(); 121 | }); 122 | }); 123 | } else { 124 | // Service worker found. Proceed as normal. 125 | registerValidSW(swUrl, config); 126 | } 127 | }) 128 | .catch(() => { 129 | console.log('No internet connection found. App is running in offline mode.'); 130 | }); 131 | } 132 | 133 | export function unregister() { 134 | if ('serviceWorker' in navigator) { 135 | navigator.serviceWorker.ready.then(registration => { 136 | registration.unregister(); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "allowJs": true, 5 | "skipLibCheck": false, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "preserve", 15 | "forceConsistentCasingInFileNames": true, 16 | "noUnusedLocals": true, 17 | "lib": [ 18 | "dom", 19 | "dom.iterable", 20 | "esnext" 21 | ] 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jsdom', 4 | snapshotSerializers: ['enzyme-to-json/serializer'], 5 | coveragePathIgnorePatterns: ['/config'], 6 | setupFilesAfterEnv: ['/config/setupTests.ts'], 7 | testPathIgnorePatterns: ['/node_modules/', '/cypress/'], 8 | transform: { 9 | '^.+\\.tsx?$': 'ts-jest', 10 | }, 11 | globals: { 12 | 'ts-jest': { 13 | diagnostics: false, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-countdown", 3 | "version": "2.3.6", 4 | "description": "A customizable countdown component for React.", 5 | "main": "./dist/index.js", 6 | "module": "./dist/index.es.js", 7 | "types": "./dist/index.d.ts", 8 | "files": [ 9 | "/dist" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/ndresx/react-countdown.git" 14 | }, 15 | "author": "Martin Veith", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/ndresx/react-countdown/issues" 19 | }, 20 | "homepage": "https://github.com/ndresx/react-countdown", 21 | "keywords": [ 22 | "react", 23 | "typescript", 24 | "countdown", 25 | "component" 26 | ], 27 | "scripts": { 28 | "build": "yarn build:clean && yarn rollup --config", 29 | "build:clean": "rm -rf ./dist && yarn test:e2e:clean && mkdir dist", 30 | "build:watch": "yarn build --watch", 31 | "clean": "rm -rf ./node_modules && rm -rf ./coverage && yarn build:clean", 32 | "lint": "yarn lint:prettier && yarn lint:tslint && yarn lint:tsc", 33 | "lint:prettier": "yarn prettier-check", 34 | "lint:tslint": "tslint --config tslint.json --project tsconfig.json ./src/**/*.{ts,tsx}", 35 | "lint:tsc": "tsc --noEmit", 36 | "prettier-check": "prettier --list-different ./src/**/*.{ts,tsx}", 37 | "prettier": "prettier --write ./src/**/*.{ts,tsx}", 38 | "test": "jest", 39 | "test:coverage": "jest --coverage", 40 | "test:e2e": "yarn start-server-and-test test:e2e:server http://localhost:1234 \"cypress run\"", 41 | "test:e2e:clean": "rm -rf ./e2e/dist", 42 | "test:e2e:dev": "yarn start-server-and-test test:e2e:server http://localhost:1234 \"cypress open\"", 43 | "test:e2e:server": "yarn build && parcel ./e2e/index.html --out-dir ./e2e/dist", 44 | "test:watch": "jest --watch" 45 | }, 46 | "dependencies": { 47 | "prop-types": "^15.7.2" 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "^7.4.5", 51 | "@babel/preset-env": "^7.4.5", 52 | "@babel/preset-react": "^7.0.0", 53 | "@babel/preset-typescript": "^7.3.3", 54 | "@types/enzyme": "^3.9.3", 55 | "@types/enzyme-adapter-react-16": "^1.0.5", 56 | "@types/enzyme-to-json": "^1.5.3", 57 | "@types/jest": "^24.0.13", 58 | "@types/react": "^16.8.19", 59 | "coveralls": "^3.0.4", 60 | "cypress": "^5.3.0", 61 | "enzyme": "^3.10.0", 62 | "enzyme-adapter-react-16": "^1.14.0", 63 | "enzyme-to-json": "^3.3.5", 64 | "jest": "^24.8.0", 65 | "parcel-bundler": "^1.12.4", 66 | "prettier": "^1.18.2", 67 | "react": "^16.8.6", 68 | "react-dom": "^16.8.6", 69 | "react-test-renderer": "^16.8.6", 70 | "rollup": "^1.14.6", 71 | "rollup-plugin-babel": "^4.3.2", 72 | "rollup-plugin-typescript2": "^0.21.1", 73 | "start-server-and-test": "^1.11.5", 74 | "ts-jest": "^24.0.2", 75 | "tslint": "^5.17.0", 76 | "tslint-config-airbnb": "^5.11.1", 77 | "tslint-config-prettier": "^1.18.0", 78 | "typescript": "^3.5.1" 79 | }, 80 | "peerDependencies": { 81 | "react": ">= 15", 82 | "react-dom": ">= 15" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | const pkg = require('./package.json'); 2 | const babel = require('rollup-plugin-babel'); 3 | const typescript = require('rollup-plugin-typescript2'); 4 | 5 | module.exports = { 6 | input: './src/index.ts', 7 | output: [ 8 | { 9 | file: pkg.main, 10 | format: 'cjs', 11 | exports: 'named', 12 | }, 13 | { 14 | file: pkg.module, 15 | format: 'es', 16 | exports: 'named', 17 | }, 18 | ], 19 | external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], 20 | plugins: [ 21 | typescript({ 22 | typescript: require('typescript'), 23 | exclude: ['**/*.test.ts?(x)'], 24 | clean: true, 25 | }), 26 | babel({ 27 | exclude: 'node_modules/**', 28 | extensions: ['.ts', '.tsx'], 29 | }), 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /src/Countdown.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount, ReactWrapper } from 'enzyme'; 3 | 4 | import Countdown, { CountdownProps } from './Countdown'; 5 | import { calcTimeDelta, formatTimeDelta } from './utils'; 6 | 7 | import { CountdownProps as LegacyCountdownProps } from './LegacyCountdown'; 8 | 9 | const timeDiff = 90110456; 10 | const now = jest.fn(() => 1482363367071); 11 | Date.now = now; 12 | 13 | const defaultStats = { 14 | total: 0, 15 | days: 0, 16 | hours: 0, 17 | minutes: 0, 18 | seconds: 0, 19 | milliseconds: 0, 20 | completed: false, 21 | }; 22 | 23 | describe('', () => { 24 | jest.useFakeTimers(); 25 | 26 | let wrapper: ReactWrapper; 27 | let countdownDate: number; 28 | const countdownMs = 10000; 29 | 30 | beforeEach(() => { 31 | Date.now = now; 32 | const date = Date.now() + countdownMs; 33 | const root = document.createElement('div'); 34 | countdownDate = date; 35 | wrapper = mount(, { attachTo: root }); 36 | }); 37 | 38 | it('should render custom renderer output', () => { 39 | wrapper = mount( 40 | ( 43 |
44 | {props.days} 45 | {props.hours} 46 | {props.minutes} 47 | {props.seconds} 48 |
49 | )} 50 | /> 51 | ); 52 | expect(wrapper).toMatchSnapshot(); 53 | }); 54 | 55 | it('should render and unmount component on countdown end', () => { 56 | const zeroPadTime = 0; 57 | 58 | class Completionist extends React.Component { 59 | componentDidMount() {} 60 | 61 | render() { 62 | return ( 63 |
64 | Completed! {this.props.name} {this.props.children} 65 |
66 | ); 67 | } 68 | } 69 | 70 | let completionist; 71 | Completionist.prototype.componentDidMount = jest.fn(); 72 | 73 | wrapper = mount( 74 | 75 | { 77 | completionist = el; 78 | }} 79 | name="master" 80 | > 81 | Another child 82 | 83 | 84 | ); 85 | expect(Completionist.prototype.componentDidMount).not.toBeCalled(); 86 | expect(wrapper).toMatchSnapshot(); 87 | 88 | // Forward in time 89 | wrapper.setProps({ date: 0 }); 90 | expect(wrapper.state().timeDelta.completed).toBe(true); 91 | expect(wrapper.props().children!.type).toBe(Completionist); 92 | expect(Completionist.prototype.componentDidMount).toBeCalled(); 93 | 94 | const computedProps = { ...wrapper.props() }; 95 | delete computedProps.children; 96 | 97 | const obj = wrapper.instance(); 98 | const { timeDelta } = wrapper.state(); 99 | expect(completionist.props).toEqual({ 100 | countdown: { 101 | ...timeDelta, 102 | api: obj.getApi(), 103 | props: wrapper.props(), 104 | formatted: formatTimeDelta(timeDelta, { zeroPadTime }), 105 | }, 106 | name: 'master', 107 | children: 'Another child', 108 | }); 109 | expect(wrapper).toMatchSnapshot(); 110 | }); 111 | 112 | it('should render with daysInHours => true', () => { 113 | wrapper = mount(); 114 | expect(wrapper).toMatchSnapshot(); 115 | }); 116 | 117 | it('should render with zeroPadDays => 3', () => { 118 | wrapper = mount(); 119 | expect(wrapper).toMatchSnapshot(); 120 | }); 121 | 122 | it('should trigger onTick and onComplete callbacks', () => { 123 | const onTick = jest.fn(stats => { 124 | expect(stats).toEqual(calcTimeDelta(countdownDate)); 125 | }); 126 | 127 | const onComplete = jest.fn(stats => { 128 | expect(stats.total).toEqual(0); 129 | }); 130 | 131 | wrapper.setProps({ onTick, onComplete }); 132 | expect(onTick).not.toBeCalled(); 133 | 134 | // Forward 6s in time 135 | now.mockReturnValue(countdownDate - 6000); 136 | jest.runTimersToTime(6000); 137 | expect(onTick.mock.calls.length).toBe(6); 138 | expect(wrapper.state().timeDelta.total).toBe(6000); 139 | 140 | wrapper.update(); 141 | expect(wrapper).toMatchSnapshot(); 142 | 143 | // Forward 3 more seconds 144 | now.mockReturnValue(countdownDate - 1000); 145 | jest.runTimersToTime(3000); 146 | expect(onTick.mock.calls.length).toBe(9); 147 | expect(wrapper.state().timeDelta.total).toBe(1000); 148 | expect(wrapper.state().timeDelta.completed).toBe(false); 149 | 150 | // The End: onComplete callback gets triggered instead of the onTick callback 151 | now.mockReturnValue(countdownDate); 152 | jest.runTimersToTime(1000); 153 | expect(onTick.mock.calls.length).toBe(9); 154 | expect(onTick).toBeCalledWith({ 155 | ...defaultStats, 156 | total: 1000, 157 | seconds: 1, 158 | }); 159 | 160 | expect(onComplete).toBeCalledTimes(1); 161 | expect(onComplete).toBeCalledWith({ ...defaultStats, completed: true }, false); 162 | expect(wrapper.state().timeDelta.completed).toBe(true); 163 | }); 164 | 165 | it('should trigger various callbacks before onComplete is called', () => { 166 | const calls: string[] = []; 167 | 168 | const onStart = jest.fn().mockImplementation(() => calls.push('onStart')); 169 | const onTick = jest.fn().mockImplementation(() => calls.push('onTick')); 170 | const onComplete = jest.fn().mockImplementation(() => calls.push('onComplete')); 171 | wrapper = mount( 172 | 173 | ); 174 | 175 | expect(calls).toEqual(['onStart']); 176 | 177 | for (let i = 1; i <= 10; i += 1) { 178 | now.mockReturnValue(countdownDate - countdownMs + i * 1000); 179 | jest.runTimersToTime(1000); 180 | } 181 | 182 | expect(calls).toEqual(['onStart', ...Array(9).fill('onTick'), 'onComplete']); 183 | }); 184 | 185 | it('should trigger onComplete callback on start if date is in the past when countdown starts', () => { 186 | const calls: string[] = []; 187 | 188 | const onStart = jest.fn().mockImplementation(() => calls.push('onStart')); 189 | const onTick = jest.fn().mockImplementation(() => calls.push('onTick')); 190 | const onComplete = jest.fn().mockImplementation(() => calls.push('onComplete')); 191 | 192 | countdownDate = Date.now() - 10000; 193 | wrapper = mount( 194 | 195 | ); 196 | 197 | expect(onStart).toHaveBeenCalledTimes(1); 198 | expect(onTick).not.toHaveBeenCalled(); 199 | expect(onComplete).toHaveBeenCalledTimes(1); 200 | expect(onComplete).toBeCalledWith({ ...defaultStats, completed: true }, true); 201 | expect(calls).toEqual(['onStart', 'onComplete']); 202 | }); 203 | 204 | it('should run through the controlled component by updating the date prop', () => { 205 | const root = document.createElement('div'); 206 | wrapper = mount(, { attachTo: root }); 207 | const obj = wrapper.instance(); 208 | const api = obj.getApi(); 209 | 210 | expect(obj.interval).toBeUndefined(); 211 | expect(wrapper.state().timeDelta.completed).toBe(false); 212 | expect(api.isCompleted()).toBe(false); 213 | 214 | wrapper.setProps({ date: 0 }); 215 | expect(wrapper.state().timeDelta.total).toBe(0); 216 | expect(wrapper.state().timeDelta.completed).toBe(true); 217 | expect(api.isCompleted()).toBe(true); 218 | }); 219 | 220 | it('should only reset time delta state when date prop is changing', () => { 221 | const root = document.createElement('div'); 222 | wrapper = mount(, { attachTo: root }); 223 | const obj = wrapper.instance(); 224 | obj.setTimeDeltaState = jest.fn(); 225 | 226 | function mergeProps(partialProps: Partial): CountdownProps { 227 | return { ...wrapper.props(), ...partialProps }; 228 | } 229 | 230 | wrapper.setProps(mergeProps({ date: 500 })); 231 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(1); 232 | 233 | wrapper.setProps(mergeProps({ intervalDelay: 999 })); 234 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(1); 235 | 236 | wrapper.setProps(mergeProps({ date: 500 })); 237 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(1); 238 | 239 | wrapper.setProps(mergeProps({ precision: 3 })); 240 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(1); 241 | 242 | wrapper.setProps(mergeProps({ date: 750 })); 243 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(2); 244 | 245 | wrapper.setProps(mergeProps({ children:
})); 246 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(2); 247 | 248 | wrapper.setProps(mergeProps({ date: 1000 })); 249 | expect(obj.setTimeDeltaState).toHaveBeenCalledTimes(3); 250 | }); 251 | 252 | it('should not (try to) set state after component unmount', () => { 253 | expect(wrapper.state().timeDelta.completed).toBe(false); 254 | 255 | now.mockReturnValue(countdownDate - 6000); 256 | jest.runTimersToTime(6000); 257 | expect(wrapper.state().timeDelta.total).toBe(6000); 258 | 259 | wrapper.instance().mounted = false; 260 | now.mockReturnValue(countdownDate - 3000); 261 | jest.runTimersToTime(3000); 262 | expect(wrapper.state().timeDelta.total).toBe(6000); 263 | }); 264 | 265 | it('should set countdown status to STOPPED if a prop-update occurs that updates a completed countdown', () => { 266 | wrapper = mount(); 267 | const obj = wrapper.instance(); 268 | const api = obj.getApi(); 269 | 270 | expect(api.isStarted()).toBe(true); 271 | 272 | wrapper.setProps({ date: countdownDate + 1000 }); 273 | expect(api.isStarted()).toBe(true); 274 | 275 | wrapper.setProps({ date: 0 }); 276 | expect(api.isCompleted()).toBe(true); 277 | 278 | wrapper.setProps({ date: countdownDate + 1000 }); 279 | expect(api.isStopped()).toBe(true); 280 | }); 281 | 282 | it('should pause => start => pause => stop and restart countdown', () => { 283 | const spies = { 284 | onMount: jest.fn(), 285 | onStart: jest.fn(), 286 | onPause: jest.fn(), 287 | onStop: jest.fn(), 288 | }; 289 | wrapper = mount(); 290 | const obj = wrapper.instance(); 291 | const api = obj.getApi(); 292 | 293 | expect(obj.offsetStartTimestamp).toBe(0); 294 | expect(obj.offsetTime).toBe(0); 295 | 296 | expect(api.isStarted()).toBe(true); 297 | expect(api.isPaused()).toBe(false); 298 | expect(api.isStopped()).toBe(false); 299 | expect(api.isCompleted()).toBe(false); 300 | expect(spies.onMount).toHaveBeenCalledTimes(1); 301 | expect(spies.onMount).toHaveBeenCalledWith({ 302 | completed: false, 303 | total: 10000, 304 | days: 0, 305 | hours: 0, 306 | minutes: 0, 307 | seconds: 10, 308 | milliseconds: 0, 309 | }); 310 | expect(spies.onStart).toHaveBeenCalledTimes(1); 311 | expect(spies.onPause).toHaveBeenCalledTimes(0); 312 | expect(spies.onStop).toHaveBeenCalledTimes(0); 313 | 314 | let runMs = 2000; 315 | const nowBeforePause = countdownDate - (countdownMs - runMs); 316 | now.mockReturnValue(nowBeforePause); 317 | jest.runTimersToTime(runMs); 318 | expect(wrapper.state().timeDelta.total).toBe(countdownMs - runMs); 319 | 320 | api.pause(); 321 | expect(api.isStarted()).toBe(false); 322 | expect(api.isPaused()).toBe(true); 323 | expect(api.isStopped()).toBe(false); 324 | expect(api.isCompleted()).toBe(false); 325 | expect(spies.onMount).toHaveBeenCalledTimes(1); 326 | expect(spies.onStart).toHaveBeenCalledTimes(1); 327 | expect(spies.onPause).toHaveBeenCalledTimes(1); 328 | expect(spies.onPause).toHaveBeenCalledWith({ 329 | completed: false, 330 | total: 8000, 331 | days: 0, 332 | hours: 0, 333 | minutes: 0, 334 | seconds: 8, 335 | milliseconds: 0, 336 | }); 337 | expect(spies.onStop).toHaveBeenCalledTimes(0); 338 | 339 | // Calling pause() a 2nd time while paused should return early 340 | api.pause(); 341 | expect(api.isPaused()).toBe(true); 342 | expect(spies.onPause).toHaveBeenCalledTimes(1); 343 | 344 | runMs += 2000; 345 | const pausedMs = 2000; 346 | now.mockReturnValue(countdownDate - (countdownMs - runMs)); 347 | jest.runTimersToTime(runMs); 348 | expect(countdownMs - runMs + pausedMs).toBe(8000); 349 | expect(wrapper.state().timeDelta.total).toBe(8000); 350 | expect(obj.offsetStartTimestamp).toBe(nowBeforePause); 351 | expect(obj.offsetTime).toBe(0); 352 | 353 | api.start(); 354 | expect(api.isStarted()).toBe(true); 355 | expect(api.isPaused()).toBe(false); 356 | expect(api.isStopped()).toBe(false); 357 | expect(api.isCompleted()).toBe(false); 358 | expect(spies.onMount).toHaveBeenCalledTimes(1); 359 | expect(spies.onStart).toHaveBeenCalledTimes(2); 360 | expect(spies.onStart).toHaveBeenCalledWith({ 361 | completed: false, 362 | total: 8000, 363 | days: 0, 364 | hours: 0, 365 | minutes: 0, 366 | seconds: 8, 367 | milliseconds: 0, 368 | }); 369 | expect(spies.onPause).toHaveBeenCalledTimes(1); 370 | expect(spies.onStop).toHaveBeenCalledTimes(0); 371 | 372 | expect(wrapper.state().timeDelta.total).toBe(8000); 373 | expect(obj.offsetStartTimestamp).toBe(0); 374 | expect(obj.offsetTime).toBe(pausedMs); 375 | 376 | runMs += 1000; 377 | now.mockReturnValue(countdownDate - (countdownMs - runMs)); 378 | jest.runTimersToTime(runMs); 379 | expect(countdownMs - runMs + pausedMs).toBe(7000); 380 | expect(wrapper.state().timeDelta.total).toBe(7000); 381 | expect(obj.offsetStartTimestamp).toBe(0); 382 | expect(obj.offsetTime).toBe(pausedMs); 383 | 384 | runMs += 1000; 385 | now.mockReturnValue(countdownDate - (countdownMs - runMs)); 386 | jest.runTimersToTime(runMs); 387 | 388 | api.pause(); 389 | expect(obj.offsetStartTimestamp).toBe(now()); 390 | expect(obj.offsetTime).toBe(2000); 391 | 392 | expect(wrapper.state().timeDelta).toEqual({ 393 | completed: false, 394 | total: 6000, 395 | days: 0, 396 | hours: 0, 397 | minutes: 0, 398 | seconds: 6, 399 | milliseconds: 0, 400 | }); 401 | 402 | runMs += 1000; 403 | now.mockReturnValue(countdownDate - (countdownMs - runMs)); 404 | jest.runTimersToTime(runMs); 405 | 406 | api.stop(); 407 | expect(obj.offsetStartTimestamp).toBe(now()); 408 | expect(obj.offsetTime).toBe(runMs); 409 | 410 | expect(api.isStarted()).toBe(false); 411 | expect(api.isPaused()).toBe(false); 412 | expect(api.isStopped()).toBe(true); 413 | expect(api.isCompleted()).toBe(false); 414 | expect(spies.onMount).toHaveBeenCalledTimes(1); 415 | expect(spies.onStart).toHaveBeenCalledTimes(2); 416 | expect(spies.onPause).toHaveBeenCalledTimes(2); 417 | expect(spies.onStop).toHaveBeenCalledTimes(1); 418 | expect(spies.onStop).toHaveBeenCalledWith({ 419 | completed: false, 420 | total: 10000, 421 | days: 0, 422 | hours: 0, 423 | minutes: 0, 424 | seconds: 10, 425 | milliseconds: 0, 426 | }); 427 | 428 | // Calling stop() a 2nd time while stopped should return early 429 | api.stop(); 430 | expect(api.isStopped()).toBe(true); 431 | expect(spies.onStop).toHaveBeenCalledTimes(1); 432 | 433 | api.start(); 434 | 435 | runMs += 10000; 436 | now.mockReturnValue(countdownDate + runMs + pausedMs); 437 | jest.runTimersToTime(countdownMs + pausedMs); 438 | expect(wrapper.state().timeDelta.total).toBe(0); 439 | expect(wrapper.state().timeDelta.completed).toBe(true); 440 | expect(api.isCompleted()).toBe(true); 441 | expect(obj.offsetStartTimestamp).toBe(0); 442 | expect(obj.offsetTime).toBe(7000); 443 | 444 | expect(spies.onMount).toHaveBeenCalledTimes(1); 445 | expect(spies.onStart).toHaveBeenCalledTimes(3); 446 | expect(spies.onPause).toHaveBeenCalledTimes(2); 447 | expect(spies.onStop).toHaveBeenCalledTimes(1); 448 | }); 449 | 450 | it('should not auto start countdown', () => { 451 | const spies = { 452 | onStart: jest.fn(), 453 | }; 454 | wrapper = mount(); 455 | const obj = wrapper.instance(); 456 | const api = obj.getApi(); 457 | 458 | expect(spies.onStart).toHaveBeenCalledTimes(0); 459 | expect(api.isStarted()).toBe(false); 460 | expect(api.isPaused()).toBe(false); 461 | expect(api.isStopped()).toBe(true); 462 | expect(api.isCompleted()).toBe(false); 463 | expect(obj).toEqual( 464 | expect.objectContaining({ 465 | offsetStartTimestamp: countdownDate - countdownMs, 466 | offsetTime: 0, 467 | }) 468 | ); 469 | 470 | api.start(); 471 | expect(spies.onStart).toHaveBeenCalledTimes(1); 472 | expect(api.isStarted()).toBe(true); 473 | expect(api.isPaused()).toBe(false); 474 | expect(api.isStopped()).toBe(false); 475 | expect(api.isCompleted()).toBe(false); 476 | expect(obj).toEqual( 477 | expect.objectContaining({ 478 | offsetStartTimestamp: 0, 479 | offsetTime: 0, 480 | }) 481 | ); 482 | 483 | // Calling start() a 2nd time while started should return early 484 | api.start(); 485 | expect(spies.onStart).toHaveBeenCalledTimes(1); 486 | }); 487 | 488 | it('should continuously call the renderer if date is in the future', () => { 489 | const renderer = jest.fn(() =>
); 490 | wrapper = mount(); 491 | expect(renderer).toHaveBeenCalledTimes(2); 492 | 493 | // Forward 1s 494 | now.mockReturnValue(countdownDate - 9000); 495 | jest.runTimersToTime(1000); 496 | expect(renderer).toHaveBeenCalledTimes(3); 497 | 498 | // Forward 2s 499 | now.mockReturnValue(countdownDate - 8000); 500 | jest.runTimersToTime(1000); 501 | expect(renderer).toHaveBeenCalledTimes(4); 502 | 503 | expect(wrapper.state().timeDelta.total).toBe(8000); 504 | expect(wrapper.state().timeDelta.completed).toBe(false); 505 | }); 506 | 507 | it('should stop immediately if date is in the past', () => { 508 | const renderer = jest.fn(() =>
); 509 | countdownDate = Date.now() - 10000; 510 | wrapper = mount(); 511 | expect(renderer).toHaveBeenCalledTimes(2); 512 | 513 | // Forward 1s 514 | now.mockReturnValue(countdownDate - 9000); 515 | jest.runTimersToTime(1000); 516 | expect(renderer).toHaveBeenCalledTimes(2); 517 | 518 | // Forward 2s 519 | now.mockReturnValue(countdownDate - 8000); 520 | jest.runTimersToTime(1000); 521 | expect(renderer).toHaveBeenCalledTimes(2); 522 | 523 | expect(wrapper.state().timeDelta.total).toBe(0); 524 | expect(wrapper.state().timeDelta.completed).toBe(true); 525 | }); 526 | 527 | it('should not stop the countdown and go into overtime', () => { 528 | const onTick = jest.fn(); 529 | wrapper = mount( 530 | 531 |
Completed? Overtime!
532 |
533 | ); 534 | const obj = wrapper.instance(); 535 | const api = obj.getApi(); 536 | 537 | // Forward 9s 538 | now.mockReturnValue(countdownDate - 1000); 539 | jest.runTimersToTime(9000); 540 | 541 | expect(wrapper.text()).toMatchInlineSnapshot(`"00:00:00:01"`); 542 | expect(onTick).toHaveBeenCalledTimes(9); 543 | 544 | // Forward 1s 545 | now.mockReturnValue(countdownDate); 546 | jest.runTimersToTime(1000); 547 | 548 | expect(wrapper.text()).toMatchInlineSnapshot(`"00:00:00:00"`); 549 | expect(onTick).toHaveBeenCalledTimes(10); 550 | expect(wrapper.state().timeDelta.total).toBe(0); 551 | expect(wrapper.state().timeDelta.completed).toBe(true); 552 | expect(api.isCompleted()).toBe(false); 553 | 554 | // Forward 1s (overtime) 555 | now.mockReturnValue(countdownDate + 1000); 556 | jest.runTimersToTime(1000); 557 | 558 | expect(wrapper.text()).toMatchInlineSnapshot(`"-00:00:00:01"`); 559 | expect(onTick).toHaveBeenCalledTimes(11); 560 | expect(wrapper.state().timeDelta.total).toBe(-1000); 561 | expect(wrapper.state().timeDelta.completed).toBe(true); 562 | expect(api.isCompleted()).toBe(false); 563 | }); 564 | 565 | describe('legacy mode', () => { 566 | class LegacyCountdownOverlay extends React.Component { 567 | render() { 568 | return
{this.props.count}
; 569 | } 570 | } 571 | 572 | it('should render legacy countdown', () => { 573 | wrapper = mount( 574 | 575 | 576 | 577 | ); 578 | expect(wrapper.find('div').text()).toBe('3'); 579 | }); 580 | 581 | it('should render legacy countdown without count prop', () => { 582 | wrapper = mount( 583 | 584 | 585 | 586 | ); 587 | expect(wrapper.find('div').text()).toBe('3'); 588 | }); 589 | 590 | it('should render null without children', () => { 591 | wrapper = mount(); 592 | expect(wrapper.html()).toBe(''); 593 | wrapper.setProps({}); 594 | wrapper.unmount(); 595 | }); 596 | 597 | it('should allow adding time in seconds', () => { 598 | const ref = React.createRef(); 599 | 600 | wrapper = mount( 601 | <> 602 | 603 | 604 | 605 | 606 | ); 607 | 608 | expect(wrapper.find('div').text()).toBe('3'); 609 | 610 | ref && ref.current && ref.current.addTime(2); 611 | jest.runOnlyPendingTimers(); 612 | wrapper.update(); 613 | 614 | expect(wrapper.find('div').text()).toBe('4'); 615 | }); 616 | 617 | it('should trigger onComplete callback when count reaches 0', () => { 618 | const ref = React.createRef(); 619 | const onComplete = jest.fn(); 620 | 621 | wrapper = mount( 622 | <> 623 | 624 | 625 | 626 | 627 | ); 628 | 629 | expect(onComplete).not.toHaveBeenCalled(); 630 | ref && ref.current && ref.current.addTime(-2); 631 | jest.runOnlyPendingTimers(); 632 | wrapper.update(); 633 | 634 | expect(onComplete).toHaveBeenCalled(); 635 | expect(wrapper.find('div').text()).toBe('1'); 636 | }); 637 | }); 638 | 639 | afterEach(() => { 640 | try { 641 | wrapper.detach(); 642 | } catch (e) {} 643 | }); 644 | }); 645 | -------------------------------------------------------------------------------- /src/Countdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | 4 | import LegacyCountdown, { CountdownProps as LegacyCountdownProps } from './LegacyCountdown'; 5 | 6 | import { 7 | calcTimeDelta, 8 | CountdownTimeDelta, 9 | CountdownTimeDeltaFormatted, 10 | CountdownTimeDeltaFormatOptions, 11 | timeDeltaFormatOptionsDefaults, 12 | formatTimeDelta, 13 | } from './utils'; 14 | 15 | interface Props { 16 | children?: React.ReactNode | undefined; 17 | key?: React.Key | undefined; 18 | ref?: React.LegacyRef | undefined; 19 | } 20 | 21 | export interface CountdownProps 22 | extends Props, 23 | CountdownTimeDeltaFormatOptions, 24 | Omit { 25 | readonly date: Date | number | string; 26 | readonly controlled?: boolean; 27 | readonly intervalDelay?: number; 28 | readonly precision?: number; 29 | readonly autoStart?: boolean; 30 | readonly overtime?: boolean; 31 | readonly className?: string; 32 | readonly children?: React.ReactElement; 33 | readonly renderer?: CountdownRendererFn; 34 | readonly now?: () => number; 35 | readonly onMount?: CountdownTimeDeltaFn; 36 | readonly onStart?: CountdownTimeDeltaFn; 37 | readonly onPause?: CountdownTimeDeltaFn; 38 | readonly onStop?: CountdownTimeDeltaFn; 39 | readonly onTick?: CountdownTimeDeltaFn; 40 | readonly onComplete?: 41 | | ((timeDelta: CountdownTimeDelta, completedOnStart: boolean) => void) 42 | | LegacyCountdownProps['onComplete']; 43 | } 44 | 45 | export interface CountdownRenderProps extends CountdownTimeDelta { 46 | readonly api: CountdownApi; 47 | readonly props: CountdownProps; 48 | readonly formatted: CountdownTimeDeltaFormatted; 49 | } 50 | 51 | export type CountdownRendererFn = (props: CountdownRenderProps) => React.ReactNode; 52 | 53 | export type CountdownTimeDeltaFn = (timeDelta: CountdownTimeDelta) => void; 54 | 55 | const enum CountdownStatus { 56 | STARTED = 'STARTED', 57 | PAUSED = 'PAUSED', 58 | STOPPED = 'STOPPED', 59 | COMPLETED = 'COMPLETED', 60 | } 61 | 62 | interface CountdownState { 63 | readonly timeDelta: CountdownTimeDelta; 64 | readonly status: CountdownStatus; 65 | } 66 | 67 | export interface CountdownApi { 68 | readonly start: () => void; 69 | readonly pause: () => void; 70 | readonly stop: () => void; 71 | readonly isStarted: () => boolean; 72 | readonly isPaused: () => boolean; 73 | readonly isStopped: () => boolean; 74 | readonly isCompleted: () => boolean; 75 | } 76 | 77 | /** 78 | * A customizable countdown component for React. 79 | * 80 | * @export 81 | * @class Countdown 82 | * @extends {React.Component} 83 | */ 84 | export default class Countdown extends React.Component { 85 | static defaultProps: Partial = { 86 | ...timeDeltaFormatOptionsDefaults, 87 | controlled: false, 88 | intervalDelay: 1000, 89 | precision: 0, 90 | autoStart: true, 91 | }; 92 | 93 | static propTypes = { 94 | date: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string, PropTypes.number]), 95 | daysInHours: PropTypes.bool, 96 | zeroPadTime: PropTypes.number, 97 | zeroPadDays: PropTypes.number, 98 | controlled: PropTypes.bool, 99 | intervalDelay: PropTypes.number, 100 | precision: PropTypes.number, 101 | autoStart: PropTypes.bool, 102 | overtime: PropTypes.bool, 103 | className: PropTypes.string, 104 | children: PropTypes.element, 105 | renderer: PropTypes.func, 106 | now: PropTypes.func, 107 | onMount: PropTypes.func, 108 | onStart: PropTypes.func, 109 | onPause: PropTypes.func, 110 | onStop: PropTypes.func, 111 | onTick: PropTypes.func, 112 | onComplete: PropTypes.func, 113 | }; 114 | 115 | mounted = false; 116 | interval: number | undefined; 117 | api: CountdownApi | undefined; 118 | 119 | initialTimestamp = this.calcOffsetStartTimestamp(); 120 | offsetStartTimestamp = this.props.autoStart ? 0 : this.initialTimestamp; 121 | offsetTime = 0; 122 | 123 | legacyMode = false; 124 | legacyCountdownRef: LegacyCountdown | null = null; 125 | 126 | constructor(props: CountdownProps) { 127 | super(props); 128 | 129 | if (props.date) { 130 | const timeDelta = this.calcTimeDelta(); 131 | this.state = { 132 | timeDelta, 133 | status: timeDelta.completed ? CountdownStatus.COMPLETED : CountdownStatus.STOPPED, 134 | }; 135 | } else { 136 | this.legacyMode = true; 137 | } 138 | } 139 | 140 | componentDidMount(): void { 141 | if (this.legacyMode) { 142 | return; 143 | } 144 | 145 | this.mounted = true; 146 | if (this.props.onMount) this.props.onMount(this.calcTimeDelta()); 147 | if (this.props.autoStart) this.start(); 148 | } 149 | 150 | componentDidUpdate(prevProps: CountdownProps): void { 151 | if (this.legacyMode) { 152 | return; 153 | } 154 | 155 | if (this.props.date !== prevProps.date) { 156 | this.initialTimestamp = this.calcOffsetStartTimestamp(); 157 | this.offsetStartTimestamp = this.initialTimestamp; 158 | this.offsetTime = 0; 159 | 160 | this.setTimeDeltaState(this.calcTimeDelta()); 161 | } 162 | } 163 | 164 | componentWillUnmount(): void { 165 | if (this.legacyMode) { 166 | return; 167 | } 168 | 169 | this.mounted = false; 170 | this.clearTimer(); 171 | } 172 | 173 | tick = (): void => { 174 | const timeDelta = this.calcTimeDelta(); 175 | const callback = timeDelta.completed && !this.props.overtime ? undefined : this.props.onTick; 176 | this.setTimeDeltaState(timeDelta, undefined, callback); 177 | }; 178 | 179 | calcTimeDelta(): CountdownTimeDelta { 180 | const { date, now, precision, controlled, overtime } = this.props; 181 | return calcTimeDelta(date!, { 182 | now, 183 | precision, 184 | controlled, 185 | offsetTime: this.offsetTime, 186 | overtime, 187 | }); 188 | } 189 | 190 | calcOffsetStartTimestamp(): number { 191 | return Date.now(); 192 | } 193 | 194 | setLegacyCountdownRef = (ref: LegacyCountdown | null): void => { 195 | this.legacyCountdownRef = ref; 196 | }; 197 | 198 | start = (): void => { 199 | if (this.isStarted()) return; 200 | 201 | const prevOffsetStartTimestamp = this.offsetStartTimestamp; 202 | this.offsetStartTimestamp = 0; 203 | this.offsetTime += prevOffsetStartTimestamp 204 | ? this.calcOffsetStartTimestamp() - prevOffsetStartTimestamp 205 | : 0; 206 | 207 | const timeDelta = this.calcTimeDelta(); 208 | this.setTimeDeltaState(timeDelta, CountdownStatus.STARTED, this.props.onStart); 209 | 210 | if (!this.props.controlled && (!timeDelta.completed || this.props.overtime)) { 211 | this.clearTimer(); 212 | this.interval = window.setInterval(this.tick, this.props.intervalDelay); 213 | } 214 | }; 215 | 216 | pause = (): void => { 217 | if (this.isPaused()) return; 218 | 219 | this.clearTimer(); 220 | this.offsetStartTimestamp = this.calcOffsetStartTimestamp(); 221 | this.setTimeDeltaState(this.state.timeDelta, CountdownStatus.PAUSED, this.props.onPause); 222 | }; 223 | 224 | stop = (): void => { 225 | if (this.isStopped()) return; 226 | 227 | this.clearTimer(); 228 | this.offsetStartTimestamp = this.calcOffsetStartTimestamp(); 229 | this.offsetTime = this.offsetStartTimestamp - this.initialTimestamp; 230 | this.setTimeDeltaState(this.calcTimeDelta(), CountdownStatus.STOPPED, this.props.onStop); 231 | }; 232 | 233 | addTime(seconds: number): void { 234 | this.legacyCountdownRef!.addTime(seconds); 235 | } 236 | 237 | clearTimer(): void { 238 | window.clearInterval(this.interval); 239 | } 240 | 241 | isStarted = (): boolean => { 242 | return this.isStatus(CountdownStatus.STARTED); 243 | }; 244 | 245 | isPaused = (): boolean => { 246 | return this.isStatus(CountdownStatus.PAUSED); 247 | }; 248 | 249 | isStopped = (): boolean => { 250 | return this.isStatus(CountdownStatus.STOPPED); 251 | }; 252 | 253 | isCompleted = (): boolean => { 254 | return this.isStatus(CountdownStatus.COMPLETED); 255 | }; 256 | 257 | isStatus(status: CountdownStatus): boolean { 258 | return this.state.status === status; 259 | } 260 | 261 | setTimeDeltaState( 262 | timeDelta: CountdownTimeDelta, 263 | status?: CountdownStatus, 264 | callback?: (timeDelta: CountdownTimeDelta) => void 265 | ): void { 266 | if (!this.mounted) return; 267 | 268 | const completing = timeDelta.completed && !this.state.timeDelta.completed; 269 | const completedOnStart = timeDelta.completed && status === CountdownStatus.STARTED; 270 | 271 | if (completing && !this.props.overtime) { 272 | this.clearTimer(); 273 | } 274 | 275 | const onDone = () => { 276 | if (callback) callback(this.state.timeDelta); 277 | 278 | if (this.props.onComplete && (completing || completedOnStart)) { 279 | this.props.onComplete(timeDelta, completedOnStart); 280 | } 281 | }; 282 | 283 | return this.setState(prevState => { 284 | let newStatus = status || prevState.status; 285 | 286 | if (timeDelta.completed && !this.props.overtime) { 287 | newStatus = CountdownStatus.COMPLETED; 288 | } else if (!status && newStatus === CountdownStatus.COMPLETED) { 289 | newStatus = CountdownStatus.STOPPED; 290 | } 291 | 292 | return { 293 | timeDelta, 294 | status: newStatus, 295 | }; 296 | }, onDone); 297 | } 298 | 299 | getApi(): CountdownApi { 300 | return (this.api = this.api || { 301 | start: this.start, 302 | pause: this.pause, 303 | stop: this.stop, 304 | isStarted: this.isStarted, 305 | isPaused: this.isPaused, 306 | isStopped: this.isStopped, 307 | isCompleted: this.isCompleted, 308 | }); 309 | } 310 | 311 | getRenderProps(): CountdownRenderProps { 312 | const { daysInHours, zeroPadTime, zeroPadDays } = this.props; 313 | const { timeDelta } = this.state; 314 | return { 315 | ...timeDelta, 316 | api: this.getApi(), 317 | props: this.props, 318 | formatted: formatTimeDelta(timeDelta, { 319 | daysInHours, 320 | zeroPadTime, 321 | zeroPadDays, 322 | }), 323 | }; 324 | } 325 | 326 | render(): React.ReactNode { 327 | if (this.legacyMode) { 328 | const { count, children, onComplete } = this.props; 329 | return ( 330 | 335 | {children} 336 | 337 | ); 338 | } 339 | 340 | const { className, overtime, children, renderer } = this.props; 341 | const renderProps = this.getRenderProps(); 342 | 343 | if (renderer) { 344 | return renderer(renderProps); 345 | } 346 | 347 | if (children && this.state.timeDelta.completed && !overtime) { 348 | return React.cloneElement(children, { countdown: renderProps }); 349 | } 350 | 351 | const { days, hours, minutes, seconds } = renderProps.formatted; 352 | return ( 353 | 354 | {renderProps.total < 0 ? '-' : ''} 355 | {days} 356 | {days ? ':' : ''} 357 | {hours}:{minutes}:{seconds} 358 | 359 | ); 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/LegacyCountdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | 4 | export interface CountdownProps { 5 | readonly count?: number; 6 | readonly children?: React.ReactElement; 7 | readonly onComplete?: () => void; 8 | } 9 | 10 | interface CountdownState { 11 | readonly count: number; 12 | } 13 | 14 | export default class Countdown extends React.Component { 15 | static propTypes = { 16 | count: PropTypes.number, 17 | children: PropTypes.element, 18 | onComplete: PropTypes.func, 19 | }; 20 | 21 | state: CountdownState = { count: this.props.count || 3 }; 22 | interval: number | undefined; 23 | 24 | componentDidMount(): void { 25 | this.startCountdown(); 26 | } 27 | 28 | componentWillUnmount(): void { 29 | clearInterval(this.interval); 30 | } 31 | 32 | startCountdown = (): void => { 33 | this.interval = window.setInterval(() => { 34 | const count = this.state.count - 1; 35 | 36 | if (count === 0) { 37 | this.stopCountdown(); 38 | this.props.onComplete && this.props.onComplete(); 39 | } else { 40 | this.setState(prevState => ({ count: prevState.count - 1 })); 41 | } 42 | }, 1000); 43 | }; 44 | 45 | stopCountdown = (): void => { 46 | clearInterval(this.interval); 47 | }; 48 | 49 | addTime = (seconds: number): void => { 50 | this.stopCountdown(); 51 | this.setState(prevState => ({ count: prevState.count + seconds }), this.startCountdown); 52 | }; 53 | 54 | render(): React.ReactNode { 55 | return this.props.children 56 | ? React.cloneElement(this.props.children, { 57 | count: this.state.count, 58 | }) 59 | : null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/__snapshots__/Countdown.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and unmount component on countdown end 1`] = ` 4 | 13 | 14 | 1 15 | : 16 | 1 17 | : 18 | 1 19 | : 20 | 50 21 | 22 | 23 | `; 24 | 25 | exports[` should render and unmount component on countdown end 2`] = ` 26 | 35 | 36 | 1 37 | : 38 | 1 39 | : 40 | 1 41 | : 42 | 50 43 | 44 | 45 | `; 46 | 47 | exports[` should render custom renderer output 1`] = ` 48 | 58 |
59 | 1 60 | 1 61 | 1 62 | 50 63 |
64 |
65 | `; 66 | 67 | exports[` should render with daysInHours => true 1`] = ` 68 | 77 | 78 | 25 79 | : 80 | 01 81 | : 82 | 50 83 | 84 | 85 | `; 86 | 87 | exports[` should render with zeroPadDays => 3 1`] = ` 88 | 98 | 99 | 010 100 | : 101 | 00 102 | : 103 | 00 104 | : 105 | 00 106 | 107 | 108 | `; 109 | 110 | exports[` should trigger onTick and onComplete callbacks 1`] = ` 111 | 219 | 220 | 00 221 | : 222 | 00 223 | : 224 | 00 225 | : 226 | 06 227 | 228 | 229 | `; 230 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | zeroPad, 3 | calcTimeDelta, 4 | formatTimeDelta, 5 | CountdownTimeDeltaOptions, 6 | CountdownTimeDelta, 7 | CountdownTimeDeltaFormatted, 8 | CountdownTimeDeltaFormatOptions, 9 | } from './utils'; 10 | 11 | export { 12 | CountdownProps, 13 | CountdownRenderProps, 14 | CountdownRendererFn, 15 | CountdownApi, 16 | } from './Countdown'; 17 | import Countdown from './Countdown'; 18 | export default Countdown; 19 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { zeroPad, calcTimeDelta } from './utils'; 2 | 3 | const timeDiff = 90110456; 4 | const now = jest.fn(() => 1482363367071); 5 | Date.now = now; 6 | 7 | const defaultStats = { 8 | total: 0, 9 | days: 0, 10 | hours: 0, 11 | minutes: 0, 12 | seconds: 0, 13 | milliseconds: 0, 14 | completed: false, 15 | }; 16 | 17 | describe('utils', () => { 18 | describe('zeroPad', () => { 19 | it('should add one 0 in front of "ab" if length is 3', () => { 20 | expect(zeroPad('ab', 3)).toBe('0ab'); 21 | }); 22 | 23 | it('should add two 0s in front of 2 if length is 3', () => { 24 | expect(zeroPad(2, 3)).toBe('002'); 25 | }); 26 | 27 | it('should add one 0 in front of 1 if length is not defined', () => { 28 | expect(zeroPad(1)).toBe('01'); 29 | }); 30 | 31 | it('should add three 0s if value is "" and length is 3', () => { 32 | expect(zeroPad('', 3)).toBe('000'); 33 | }); 34 | 35 | it('should not zero-pad 1 if length is 0 or 1', () => { 36 | expect(zeroPad(1, 0)).toBe('1'); 37 | expect(zeroPad(1, 1)).toBe('1'); 38 | }); 39 | 40 | it('should not zero-pad 123 if length is 3', () => { 41 | expect(zeroPad(123, 3)).toBe('123'); 42 | expect(zeroPad(123, 4)).toBe('0123'); 43 | }); 44 | 45 | it('should zero-pad prefixed numbers', () => { 46 | expect(zeroPad(-1, 1)).toBe('-1'); 47 | expect(zeroPad(-1, 2)).toBe('-01'); 48 | expect(zeroPad(-1, 3)).toBe('-001'); 49 | expect(zeroPad('+12.34', 1)).toBe('+12.34'); 50 | expect(zeroPad('+12.34', 2)).toBe('+12.34'); 51 | expect(zeroPad('+12.34', 3)).toBe('+012.34'); 52 | }); 53 | }); 54 | 55 | describe('calcTimeDelta', () => { 56 | it('should return a time difference of 0s', () => { 57 | expect(calcTimeDelta(Date.now())).toEqual({ 58 | ...defaultStats, 59 | completed: true, 60 | }); 61 | }); 62 | 63 | it('should return a time difference of 0s if values for start and current date are the same', () => { 64 | expect(calcTimeDelta(Date.now())).toEqual({ 65 | ...defaultStats, 66 | completed: true, 67 | }); 68 | expect(calcTimeDelta(Date.now() + 10, { now: () => Date.now() + 10 })).toEqual({ 69 | ...defaultStats, 70 | completed: true, 71 | }); 72 | }); 73 | 74 | it('should calculate the time difference with a precision of 0', () => { 75 | expect(calcTimeDelta(Date.now() + timeDiff)).toEqual({ 76 | total: timeDiff - 456, 77 | days: 1, 78 | hours: 1, 79 | minutes: 1, 80 | seconds: 50, 81 | milliseconds: 0, 82 | completed: false, 83 | }); 84 | }); 85 | 86 | it('should calculate the time difference with a precision of 3', () => { 87 | expect(calcTimeDelta(Date.now() + timeDiff, { precision: 3 })).toEqual({ 88 | total: timeDiff, 89 | days: 1, 90 | hours: 1, 91 | minutes: 1, 92 | seconds: 50, 93 | milliseconds: 456, 94 | completed: false, 95 | }); 96 | }); 97 | 98 | it('should calculate the time difference by passing a date string', () => { 99 | Date.now = jest.fn(() => new Date('Thu Dec 22 2016 00:36:07').getTime()); 100 | expect(calcTimeDelta('Thu Dec 23 2017 01:38:10:456', { precision: 3 })).toEqual({ 101 | total: 31626123456, 102 | days: 366, 103 | hours: 1, 104 | minutes: 2, 105 | seconds: 3, 106 | milliseconds: 456, 107 | completed: false, 108 | }); 109 | }); 110 | 111 | it('should calculate the time difference when controlled is true', () => { 112 | const total = 91120003; 113 | expect(calcTimeDelta(total, { controlled: true })).toEqual({ 114 | total: total - 3, 115 | days: 1, 116 | hours: 1, 117 | minutes: 18, 118 | seconds: 40, 119 | milliseconds: 0, 120 | completed: false, 121 | }); 122 | 123 | expect(calcTimeDelta(total, { precision: 3, controlled: true })).toEqual({ 124 | total, 125 | days: 1, 126 | hours: 1, 127 | minutes: 18, 128 | seconds: 40, 129 | milliseconds: 3, 130 | completed: false, 131 | }); 132 | }); 133 | 134 | it('should return a time difference of 0s', () => { 135 | const date = new Date(); 136 | date.getTime = jest.fn(() => Date.now() + 1000); 137 | expect(calcTimeDelta(date)).toEqual({ 138 | total: 1000, 139 | days: 0, 140 | hours: 0, 141 | minutes: 0, 142 | seconds: 1, 143 | milliseconds: 0, 144 | completed: false, 145 | }); 146 | }); 147 | 148 | it('should calculate the time difference with custom offset', () => { 149 | const date = new Date(); 150 | date.getTime = jest.fn(() => Date.now() + 1000); 151 | expect(calcTimeDelta(date, { offsetTime: 1000 })).toEqual({ 152 | total: 2000, 153 | days: 0, 154 | hours: 0, 155 | minutes: 0, 156 | seconds: 2, 157 | milliseconds: 0, 158 | completed: false, 159 | }); 160 | }); 161 | 162 | it('should calculate the time difference when overtime is true', () => { 163 | const date = new Date(); 164 | date.getTime = jest.fn(() => Date.now() + 1000); 165 | expect(calcTimeDelta(date, { overtime: true })).toEqual({ 166 | total: 1000, 167 | days: 0, 168 | hours: 0, 169 | minutes: 0, 170 | seconds: 1, 171 | milliseconds: 0, 172 | completed: false, 173 | }); 174 | }); 175 | 176 | it('should calculate the time difference when completing while in overtime', () => { 177 | const date = new Date(); 178 | date.getTime = jest.fn(() => Date.now()); 179 | expect(calcTimeDelta(date, { overtime: true })).toEqual({ 180 | total: 0, 181 | days: 0, 182 | hours: 0, 183 | minutes: 0, 184 | seconds: 0, 185 | milliseconds: 0, 186 | completed: true, 187 | }); 188 | }); 189 | 190 | it('should calculate the time difference when going into overtime', () => { 191 | const date = new Date(); 192 | date.getTime = jest.fn(() => Date.now() - 1000); 193 | expect(calcTimeDelta(date, { overtime: true })).toEqual({ 194 | total: -1000, 195 | days: 0, 196 | hours: 0, 197 | minutes: 0, 198 | seconds: 1, 199 | milliseconds: 0, 200 | completed: true, 201 | }); 202 | }); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export interface CountdownTimeDeltaOptions { 2 | readonly now?: () => number; 3 | readonly precision?: number; 4 | readonly controlled?: boolean; 5 | readonly offsetTime?: number; 6 | readonly overtime?: boolean; 7 | } 8 | 9 | export interface CountdownTimeDelta { 10 | readonly total: number; 11 | readonly days: number; 12 | readonly hours: number; 13 | readonly minutes: number; 14 | readonly seconds: number; 15 | readonly milliseconds: number; 16 | readonly completed: boolean; 17 | } 18 | 19 | export interface CountdownTimeDeltaFormatted { 20 | readonly days: string; 21 | readonly hours: string; 22 | readonly minutes: string; 23 | readonly seconds: string; 24 | } 25 | 26 | export interface CountdownTimeDeltaFormatOptions { 27 | readonly daysInHours?: boolean; 28 | readonly zeroPadTime?: number; 29 | readonly zeroPadDays?: number; 30 | } 31 | 32 | /** 33 | * Pads a given string or number with zeros. 34 | * 35 | * @export 36 | * @param {number|string} value Value to zero-pad. 37 | * @param {number} [length=2] Amount of characters to pad. 38 | * @returns Left-padded number/string. 39 | */ 40 | export function zeroPad(value: number | string, length: number = 2): string { 41 | const strValue = String(value); 42 | if (length === 0) return strValue; 43 | const match = strValue.match(/(.*?)([0-9]+)(.*)/); 44 | const prefix = match ? match[1] : ''; 45 | const suffix = match ? match[3] : ''; 46 | const strNo = match ? match[2] : strValue; 47 | const paddedNo = 48 | strNo.length >= length 49 | ? strNo 50 | : ([...Array(length)].map(() => '0').join('') + strNo).slice(length * -1); 51 | return `${prefix}${paddedNo}${suffix}`; 52 | } 53 | 54 | export const timeDeltaFormatOptionsDefaults: CountdownTimeDeltaFormatOptions = { 55 | daysInHours: false, 56 | zeroPadTime: 2, 57 | }; 58 | 59 | /** 60 | * Calculates the time difference between a given end date and the current date. 61 | * 62 | * @export 63 | * @param {Date|number|string} date Date or timestamp representation of the end date. 64 | * @param {CountdownTimeDeltaOptions} [options] 65 | * {function} [now=Date.now] Alternative function for returning the current date. 66 | * {number} [precision=0] The precision on a millisecond basis. 67 | * {boolean} [controlled=false] Defines whether the calculated value is already provided as the time difference or not. 68 | * {number} [offsetTime=0] Defines the offset time that gets added to the start time; only considered if controlled is false. 69 | * {boolean} [overtime=false] Defines whether the time delta can go into overtime and become negative or not. 70 | * @returns Time delta object that includes details about the time difference. 71 | */ 72 | export function calcTimeDelta( 73 | date: Date | string | number, 74 | options: CountdownTimeDeltaOptions = {} 75 | ): CountdownTimeDelta { 76 | const { now = Date.now, precision = 0, controlled, offsetTime = 0, overtime } = options; 77 | let startTimestamp: number; 78 | 79 | if (typeof date === 'string') { 80 | startTimestamp = new Date(date).getTime(); 81 | } else if (date instanceof Date) { 82 | startTimestamp = date.getTime(); 83 | } else { 84 | startTimestamp = date; 85 | } 86 | 87 | if (!controlled) { 88 | startTimestamp += offsetTime; 89 | } 90 | 91 | const timeLeft = controlled ? startTimestamp : startTimestamp - now(); 92 | const clampedPrecision = Math.min(20, Math.max(0, precision)); 93 | const total = Math.round( 94 | parseFloat(((overtime ? timeLeft : Math.max(0, timeLeft)) / 1000).toFixed(clampedPrecision)) * 95 | 1000 96 | ); 97 | 98 | const seconds = Math.abs(total) / 1000; 99 | 100 | return { 101 | total, 102 | days: Math.floor(seconds / (3600 * 24)), 103 | hours: Math.floor((seconds / 3600) % 24), 104 | minutes: Math.floor((seconds / 60) % 60), 105 | seconds: Math.floor(seconds % 60), 106 | milliseconds: Number(((seconds % 1) * 1000).toFixed()), 107 | completed: total <= 0, 108 | }; 109 | } 110 | 111 | /** 112 | * Formats a given countdown time delta object. 113 | * 114 | * @export 115 | * @param {CountdownTimeDelta} timeDelta The time delta object to be formatted. 116 | * @param {CountdownTimeDeltaFormatOptions} [options] 117 | * {boolean} [daysInHours=false] Days are calculated as hours. 118 | * {number} [zeroPadTime=2] Length of zero-padded output, e.g.: 00:01:02 119 | * {number} [zeroPadDays=zeroPadTime] Length of zero-padded days output, e.g.: 01 120 | * @returns {CountdownTimeDeltaFormatted} Formatted time delta object. 121 | */ 122 | export function formatTimeDelta( 123 | timeDelta: CountdownTimeDelta, 124 | options?: CountdownTimeDeltaFormatOptions 125 | ): CountdownTimeDeltaFormatted { 126 | const { days, hours, minutes, seconds } = timeDelta; 127 | const { daysInHours, zeroPadTime, zeroPadDays = zeroPadTime } = { 128 | ...timeDeltaFormatOptionsDefaults, 129 | ...options, 130 | }; 131 | 132 | const zeroPadTimeLength = Math.min(2, zeroPadTime); 133 | const formattedHours = daysInHours 134 | ? zeroPad(hours + days * 24, zeroPadTime) 135 | : zeroPad(hours, zeroPadTimeLength); 136 | 137 | return { 138 | days: daysInHours ? '' : zeroPad(days, zeroPadDays), 139 | hours: formattedHours, 140 | minutes: zeroPad(minutes, zeroPadTimeLength), 141 | seconds: zeroPad(seconds, zeroPadTimeLength), 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "es2015", 5 | "esModuleInterop": true, 6 | "strict": true, 7 | "strictNullChecks": true, 8 | "noImplicitAny": false, 9 | "removeComments": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "rootDir": "./src", 14 | "outDir": "./dist", 15 | "declaration": true, 16 | "noUnusedLocals": true 17 | }, 18 | "include": ["./src/**/*.tsx", "./src/**/*.ts"], 19 | "exclude": ["dist", "examples"] 20 | } 21 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint-config-airbnb", "tslint-config-prettier"], 4 | "rules": { 5 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case"], 6 | "import-name": false, 7 | "object-shorthand-properties-first": false 8 | }, 9 | "rulesDirectory": [] 10 | } 11 | --------------------------------------------------------------------------------