diff --git a/src/hooks/useCountdown/index.ts b/src/hooks/useCountdown/index.ts new file mode 100644 index 00000000..6ffb0316 --- /dev/null +++ b/src/hooks/useCountdown/index.ts @@ -0,0 +1 @@ +export { useCountdown } from './useCountdown.ts'; diff --git a/src/hooks/useCountdown/useCountdown.spec.ts b/src/hooks/useCountdown/useCountdown.spec.ts new file mode 100644 index 00000000..7f57d83c --- /dev/null +++ b/src/hooks/useCountdown/useCountdown.spec.ts @@ -0,0 +1,252 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCountdown } from './useCountdown.ts'; + +describe('useCountdown', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize with countStart value', () => { + const { result } = renderHook(() => useCountdown(10, {})); + + expect(result.current[0]).toBe(10); + }); + + it('should decrement count when countdown is started', () => { + const { result } = renderHook(() => + useCountdown(10, { + interval: 1000, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(9); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(8); + }); + + it('should increment count when isIncrement is true', () => { + const { result } = renderHook(() => + useCountdown(0, { + countStop: 3, + isIncrement: true, + interval: 1000, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(1); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(2); + }); + + it('should stop countdown when count equals countStop value', () => { + const { result } = renderHook(() => + useCountdown(2, { + countStop: 0, + interval: 1000, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(1); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(0); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(0); + }); + + it('should reset countdown when reset is called', () => { + const { result } = renderHook(() => + useCountdown(10, { + interval: 1000, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(result.current[0]).toBe(8); + + act(() => { + result.current[1].reset(); + }); + + expect(result.current[0]).toBe(10); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(10); + }); + + it('should pause countdown when pause is called', () => { + const { result } = renderHook(() => + useCountdown(10, { + interval: 1000, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(9); + + act(() => { + result.current[1].pause(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(9); + }); + + it('should resume countdown when resume is called', () => { + const { result } = renderHook(() => + useCountdown(10, { + interval: 1000, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(9); + + act(() => { + result.current[1].pause(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(9); + + act(() => { + result.current[1].resume(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current[0]).toBe(8); + }); + + it('should call onCountChange when count changes', () => { + const onCountChange = vi.fn(); + const { result } = renderHook(() => + useCountdown(10, { + interval: 1000, + onCountChange, + }) + ); + + expect(onCountChange).toHaveBeenCalledWith(10); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onCountChange).toHaveBeenCalledWith(9); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onCountChange).toHaveBeenCalledWith(8); + }); + + it('should call onComplete when countdown reaches countStop', () => { + const onComplete = vi.fn(); + const { result } = renderHook(() => + useCountdown(2, { + countStop: 0, + interval: 1000, + onComplete, + }) + ); + + act(() => { + result.current[1].start(); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onComplete).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useCountdown/useCountdown.ts b/src/hooks/useCountdown/useCountdown.ts new file mode 100644 index 00000000..31950b0e --- /dev/null +++ b/src/hooks/useCountdown/useCountdown.ts @@ -0,0 +1,118 @@ +import { useCallback, useEffect } from 'react'; + +import { useBooleanState } from '../useBooleanState/index.ts'; +import { useCounter } from '../useCounter/index.ts'; +import { useInterval } from '../useInterval/index.ts'; + +type UseCountdownOptions = { + countStop?: number; + interval?: number; + isIncrement?: boolean; + onCountChange?: (count: number) => void; + onComplete?: () => void; +}; + +type UseCountdownControllers = { + start: () => void; + pause: () => void; + resume: () => void; + reset: () => void; +}; + +type UseCountdownReturn = [number, UseCountdownControllers]; + +/** + * @description + * `useCountdown` is a React hook that manages a countdown timer. + * It provides functions to start, pause, resume, and reset the countdown. + * + * @param {number} countStart - Starting value for the countdown + * @param {UseCountdownOptions} options - Countdown options + * @param {number} [options.countStop=0] - Value at which the countdown stops (default: 0) + * @param {number} [options.interval=1000] - Interval between counts (in milliseconds, default: 1000) + * @param {boolean} [options.isIncrement=false] - Whether to use increment mode (default: false, decreasing mode) + * @param {function} [options.onCountChange] - Callback function that is called whenever the count changes + * @param {function} [options.onComplete] - Callback function that is called when the countdown completes + * + * @returns {UseCountdownReturn} [count, controllers] + * - count `number` - Current count value + * - controllers `UseCountdownControllers` - Countdown control functions + * - start `() => void` - Function to start the countdown (resets first, then starts) + * - pause `() => void` - Function to pause the countdown + * - resume `() => void` - Function to resume the paused countdown + * - reset `() => void` - Function to reset the countdown (stops and returns to initial value) + * + * @example + * import { useCountdown } from 'react-simplikit'; + * + * function Timer() { + * const [count, { start, pause, resume, reset }] = useCountdown(60, { + * countStop: 0, + * interval: 1000, + * onCountChange: (count) => console.log(`Current count: ${count}`), + * onComplete: () => console.log('Countdown complete!'), + * }); + * + * return ( + *
Time remaining: {count} seconds
+ * + * + * + * + *