diff --git a/package.json b/package.json index e4335c8..969a00b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "rax-hooks", + "private": true, "description": "Contains rax hooks that are used very frequently", "scripts": { "start": "npm run clean && node ./scripts/compile-packages.js --watch", @@ -20,6 +21,9 @@ "rax", "hooks" ], + "workspaces": [ + "packages/*" + ], "config": { "ghooks": { "commit-msg": "./scripts/validate-commit-msg.js" @@ -70,7 +74,7 @@ "lerna": "^3.19.0", "minimatch": "^3.0.4", "minimist": "^1.2.0", - "rax": "^1.1.0", + "rax": "1.1.4", "rax-test-renderer": "^1.0.0", "semver": "^7.1.2", "semver-regex": "^2.0.0", diff --git a/packages/useCountDown/README.md b/packages/useCountDown/README.md index 312464e..58ed796 100644 --- a/packages/useCountDown/README.md +++ b/packages/useCountDown/README.md @@ -2,7 +2,7 @@ npm package npm downloads -A countdown hooks which will return the left days/hours/minutes/seconds state. +A countdown hooks which will return the left time(in millisecond). ## Install @@ -12,23 +12,40 @@ $ npm install rax-use-countdown --save ## API -The API will recevie two params -- `start`/`end`. +The API will recevie two params -- `timeToCount`/`interval`. | | Type | Description | | ----- | -------- | ----------- | -| start | `number` | Start time | -| ent | `number` | End time | +| timeToCount | `number` | total time to count, in millisecond | +| interval | `number` | interval time on every tick, in millisecond | +| events | `object` | onStart, onTick, onPause, onResume, onCompleted, onReset | ## Example ```jsx -import { createElement } from 'rax'; +import { createElement, useEffect } from 'rax'; import useCountDown from 'rax-use-countdown'; function Example() { - const now = Date.now(); - const { days, hours, minutes, seconds } = useCountDown(now, now - 10000000); - - return
There only left {days}days {hours}hours {minutes}minutes {seconds}seconds
; + // countdown 10s with 100ms interval + const [timeLeft, { start, pause, resume, reset }] = useCountDown(10 * 1000, 100, { + onStart() {}, + onTick(timeLeft) { + // countdown tick, unit of `timeLeft` is millisecond + }, + onPause() {}, + onResume() {}, + onCompleted() { + // countdown completed + }, + onReset() {} + }); + + useEffect(() => { + start(); + }, []); + + // you can format timeLeft by yourself + return
There only left { timeLeft } milliseconds
; } ``` diff --git a/packages/useCountDown/package.json b/packages/useCountDown/package.json index b362e28..2b11a11 100644 --- a/packages/useCountDown/package.json +++ b/packages/useCountDown/package.json @@ -1,6 +1,6 @@ { "name": "rax-use-countdown", - "version": "1.0.2", + "version": "2.0.0", "description": "Rax useCountDown hook", "license": "BSD-3-Clause", "main": "lib/index.js", diff --git a/packages/useCountDown/src/__tests__/useCountDown.js b/packages/useCountDown/src/__tests__/useCountDown.js index bf0551f..1c83145 100644 --- a/packages/useCountDown/src/__tests__/useCountDown.js +++ b/packages/useCountDown/src/__tests__/useCountDown.js @@ -1,4 +1,4 @@ -import { createElement } from 'rax'; +import { createElement, useEffect } from 'rax'; import renderer from 'rax-test-renderer'; import useCountDown from '..'; @@ -13,21 +13,196 @@ function asyncFn(delay, val) { describe('useCountDown', () => { it('3 seconds count down', async() => { function App() { - const now = Date.now(); - const { days, hours, minutes, seconds } = useCountDown(now + 3000, now); + const [timeLeft, { start }] = useCountDown(3 * 1000, 1000); - return
{days}:{hours}:{minutes}:{seconds}
; + useEffect(() => { + start(); + }, []); + + return
{timeLeft}
; } const tree = renderer.create(); // For render await asyncFn(200); + expect(tree.toJSON().children.join('')).toEqual('3000'); + + await asyncFn(1000); + expect(tree.toJSON().children.join('')).toEqual('2000'); + + await asyncFn(2000); + expect(tree.toJSON().children.join('')).toEqual('0'); + }); + + it('100ms interval', async() => { + function App() { + const [timeLeft, { start }] = useCountDown(1000, 100); + + useEffect(() => { + start(); + }, []); + + return
{timeLeft}
; + } + + const tree = renderer.create(); + + // For render + await asyncFn(50); + expect(tree.toJSON().children.join('')).toEqual('1000'); + + await asyncFn(100); + expect(tree.toJSON().children.join('')).toEqual('900'); + + await asyncFn(100); + expect(tree.toJSON().children.join('')).toEqual('800'); + + await asyncFn(700); + expect(tree.toJSON().children.join('')).toEqual('100'); + + await asyncFn(100); + expect(tree.toJSON().children.join('')).toEqual('0'); + }); + + it('pause', async() => { + const mockPause = jest.fn(); + function App() { + const [timeLeft, { start, pause }] = useCountDown(1000, 100, { + onPause: mockPause, + }); + + useEffect(() => { + start(); + + setTimeout(() => pause(), 550); + }, []); + + return
{timeLeft}
; + } + + const tree = renderer.create(); + + // For render + await asyncFn(150); + expect(tree.toJSON().children.join('')).toEqual('900'); + + await asyncFn(500); + expect(tree.toJSON().children.join('')).toEqual('500'); + await asyncFn(500); + expect(tree.toJSON().children.join('')).toEqual('500'); + expect(mockPause).toHaveBeenCalledTimes(1); + }); + + it('resume', async() => { + const mockPause = jest.fn(); + const mockResume = jest.fn(); + function App() { + const [timeLeft, { start, pause, resume }] = useCountDown(1000, 100, { + onPause: mockPause, + onResume: mockResume, + }); + + useEffect(() => { + start(); + + setTimeout(() => pause(), 550); + setTimeout(() => resume(), 1100); + }, []); - expect(tree.toJSON().children.join('')).toEqual('0:0:0:3'); + return
{timeLeft}
; + } + + const tree = renderer.create(); + + // For render + await asyncFn(150); + expect(tree.toJSON().children.join('')).toEqual('900'); + + await asyncFn(500); + expect(tree.toJSON().children.join('')).toEqual('500'); + + // still paused + await asyncFn(100); + expect(tree.toJSON().children.join('')).toEqual('500'); + + await asyncFn(500); + expect(tree.toJSON().children.join('')).toEqual('400'); + await asyncFn(450); + expect(tree.toJSON().children.join('')).toEqual('0'); + + expect(mockPause).toHaveBeenCalledTimes(1); + expect(mockResume).toHaveBeenCalledTimes(1); + }); + + it('reset', async() => { + const mockReset = jest.fn(); + + function App() { + const [timeLeft, { start, reset }] = useCountDown(1000, 100, { + onReset: mockReset, + }); + + useEffect(() => { + start(); + setTimeout(() => reset(), 550); + }, []); + + return
{timeLeft}
; + } + + const tree = renderer.create(); + + // For render + await asyncFn(150); + + expect(tree.toJSON().children.join('')).toEqual('900'); + + await asyncFn(500); + expect(tree.toJSON().children.join('')).toEqual('1000'); + + await asyncFn(100); + expect(tree.toJSON().children.join('')).toEqual('1000'); + + expect(mockReset).toHaveBeenCalledTimes(1); + }); + + it('events', async() => { + const mockStart = jest.fn(); + const mockTick = jest.fn(); + const mockCompleted = jest.fn(); + function App() { + const [timeLeft, { start }] = useCountDown(1000, 100, { + onStart: mockStart, + onTick: mockTick, + onCompleted: mockCompleted, + }); + + useEffect(() => { + start(); + }, []); + + return
{timeLeft}
; + } + + const tree = renderer.create(); + + // For render + await asyncFn(1100); - await asyncFn(3000); + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockCompleted).toHaveBeenCalledTimes(1); - expect(tree.toJSON().children.join('')).toEqual('0:0:0:0'); + expect(mockTick).toHaveBeenCalledTimes(10); + expect(mockTick).toHaveBeenNthCalledWith(1, 1000); + expect(mockTick).toHaveBeenNthCalledWith(2, 900); + expect(mockTick).toHaveBeenNthCalledWith(3, 800); + expect(mockTick).toHaveBeenNthCalledWith(4, 700); + expect(mockTick).toHaveBeenNthCalledWith(5, 600); + expect(mockTick).toHaveBeenNthCalledWith(6, 500); + expect(mockTick).toHaveBeenNthCalledWith(7, 400); + expect(mockTick).toHaveBeenNthCalledWith(8, 300); + expect(mockTick).toHaveBeenNthCalledWith(9, 200); + expect(mockTick).toHaveBeenNthCalledWith(10, 100); }); }); diff --git a/packages/useCountDown/src/index.js b/packages/useCountDown/src/index.js index f9d78b1..9d6bee0 100644 --- a/packages/useCountDown/src/index.js +++ b/packages/useCountDown/src/index.js @@ -1,91 +1,123 @@ -import { useRef, useState, useEffect } from 'rax'; +import { useState, useRef, useEffect, useMemo, useCallback } from 'rax'; -const DAY_SECOND = 24 * 3600; -const HOUR_SECOND = 3600; -const MINUTES_SECOND = 60; +// use setTimeout/clearTimeout as fallback +const rAF = typeof requestAnimationFrame !== 'undefined' ? + requestAnimationFrame : + setTimeout; +const cAF = typeof cancelAnimationFrame !== 'undefined' ? + cancelAnimationFrame : + clearTimeout; -export default function(start, end) { - if (!isNumber(start) || !isNumber(end)) { - throw new Error('Start or end time should be number.'); +const useCountDown = (timeToCount = 60 * 1000, interval = 1000, events = {}) => { + const {onStart, onTick, onPause, onResume, onCompleted, onReset} = events; + const [timeLeft, setTimeLeft] = useState(timeToCount); + const timer = useRef({}); + + useEffect(() => { + if (timeLeft > 0) { + if (typeof onTick === 'function') { + onTick(timeLeft); + } + } else if (timeLeft === 0) { + if (typeof onCompleted === 'function') { + onCompleted(); + } + } + }, [timeLeft]); + + const run = (ts) => { + const timestamp = ts || new Date().getTime(); + + if (!timer.current.started) { + timer.current.started = timestamp; + } + + const elapsed = Math.max(timestamp - timer.current.started, 0); + const totalInterval = Math.round(elapsed / interval) * interval; + const time = timer.current.timeToCount - totalInterval; + + if (time !== timer.current.timeLeft) { + timer.current.timeLeft = time; + setTimeLeft(time); + } + + if (elapsed < timer.current.timeToCount) { + // not finished + timer.current.requestId = rAF(run, interval); + } else { + // finished + timer.current = {}; + setTimeLeft(0); + } }; - if (start < end) { - throw new Error('Start time should be greater than end time.'); - } + const start = useCallback( + (ttc) => { + cAF(timer.current.requestId); - const ref = useRef(null); + const newTimeToCount = ttc !== undefined ? ttc : timeToCount; + timer.current.started = null; + timer.current.timeToCount = newTimeToCount; + timer.current.requestId = rAF(run, interval); - if (!ref.remainTime) { - ref.remainTime = formatTime(parseInt((start - end) / 1000)); - } - const [timeLeft, setTimeLeft] = useState(ref.remainTime); + setTimeLeft(newTimeToCount); - useEffect(() => { - let shouldStop = false; - timeCountDown(ref, () => { - let remainTime = ref.remainTime; - if (remainTime.seconds > 0) { - remainTime.seconds--; - } else { - if (remainTime.minutes > 0) { - remainTime.seconds = 59; - remainTime.minutes--; - } else { - if (remainTime.hours > 0) { - Object.assign(remainTime, { - hours: remainTime.hours - 1, - minutes: 59, - seconds: 59, - }); - } else { - if (remainTime.days > 0) { - Object.assign(remainTime, { - days: remainTime.days - 1, - hours: 23, - minutes: 59, - seconds: 59, - }); - } else { - Object.assign(remainTime, { - days: 0, - hours: 0, - minutes: 0, - seconds: 0, - }); - shouldStop = true; - } - } + if (typeof onStart === 'function') { + onStart(); + } + }, + [interval] + ); + + const pause = useCallback( + () => { + cAF(timer.current.requestId); + timer.current.started = null; + timer.current.timeToCount = timer.current.timeLeft; + if (typeof onPause === 'function') { + onPause(); + } + }, + [] + ); + + const resume = useCallback( + () => { + if (!timer.current.started && timer.current.timeLeft > 0) { + cAF(timer.current.requestId); + timer.current.requestId = rAF(run, interval); + if (typeof onResume === 'function') { + onResume(); } } - setTimeLeft({ - ...remainTime - }); - return shouldStop; - }); - return () => clearTimeout(ref.id); - }, []); + }, + [interval] + ); - return timeLeft; -} + const reset = useCallback( + () => { + if (timer.current.timeLeft) { + cAF(timer.current.requestId); + timer.current = {}; + setTimeLeft(timeToCount); + if (typeof onReset === 'function') { + onReset(); + } + } + }, + [timeToCount] + ); -function formatTime(difference) { - return { - days: parseInt(difference / DAY_SECOND), - hours: parseInt(difference % DAY_SECOND / HOUR_SECOND), - minutes: parseInt(difference % HOUR_SECOND / MINUTES_SECOND), - seconds: difference % MINUTES_SECOND, - }; -} + const actions = useMemo( + () => ({ start, pause, resume, reset }), + [] + ); + + useEffect(() => { + return () => cAF(timer.current.requestId); + }, []); -function isNumber(val) { - return typeof val === 'number'; -} + return [timeLeft, actions]; +}; -function timeCountDown(ref, callback) { - ref.id = setTimeout(() => { - const shouldStop = callback(); - if (!shouldStop) { - timeCountDown(ref, callback); - } - }, 1000); -} +export default useCountDown; \ No newline at end of file diff --git a/packages/useRouter/package.json b/packages/useRouter/package.json index d337fa4..f34df70 100644 --- a/packages/useRouter/package.json +++ b/packages/useRouter/package.json @@ -23,7 +23,7 @@ "devDependencies": { "driver-server": "^1.0.0", "history": "^5.0.0", - "rax": "^1.0.0" + "rax-test-renderer": "^1.0.0" }, "dependencies": { "path-to-regexp": "^6.0.0"