Skip to content

Commit 77d836b

Browse files
committed
main 🧊 v0.3.13
1 parent 3b3f5ae commit 77d836b

File tree

7 files changed

+314
-1
lines changed

7 files changed

+314
-1
lines changed

‎packages/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@siberiacancode/reactuse",
3-
"version": "0.3.12",
3+
"version": "0.3.13",
44
"description": "The ultimate collection of react hooks",
55
"author": {
66
"name": "SIBERIA CAN CODE 🧊",

‎packages/core/src/bundle/hooks/time.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// timing
22
export * from './useInterval/useInterval';
3+
export * from './useProgress/useProgress';
34
export * from './useStopwatch/useStopwatch';
45
export * from './useTime/useTime';
56
export * from './useTimeout/useTimeout';
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
3+
const resolveAutoIncrement = (progress, trickleRate) => {
4+
if (progress < 0.25) return 0.12 + trickleRate * Math.random();
5+
if (progress < 0.5) return 0.08 + trickleRate * Math.random();
6+
if (progress < 0.8) return 0.03 + trickleRate * Math.random();
7+
if (progress < 0.95) return 0.01 + (trickleRate / 2) * Math.random();
8+
if (progress < 0.99) return 0.005 * Math.random();
9+
return 0;
10+
};
11+
/**
12+
* @name useProgress
13+
* @description - Hook that creates a lightweight progress bar state with NProgress-like behavior
14+
* @category Time
15+
* @usage medium
16+
*
17+
* @param {number} [initialProgress] Initial progress value in range 0..1
18+
* @param {boolean} [options.active] Controls progress externally (true -> start, false -> done)
19+
* @param {number} [options.maximum=0.95] Maximum value when progress starts
20+
* @param {number} [options.speed=250] Auto increment interval in milliseconds
21+
* @param {number} [options.rate=0.02] Additional random increment amount on each tick
22+
* @param {number} [options.doneResetDelay=250] Delay before reset to null after done
23+
* @returns {UseProgressReturn} Current progress state and control methods
24+
*
25+
* @example
26+
* const { value, active, start, done, inc, set, remove } = useProgress(0.2);
27+
*/
28+
export const useProgress = (initialValue = 0, options = {}) => {
29+
const speed = Math.max(options.speed ?? 250, 16);
30+
const rate = clamp(options.rate ?? 0.02, 0, 0.3);
31+
const maximum = options.maximum ?? 0.98;
32+
const [value, setValue] = useState(initialValue);
33+
const [active, setActive] = useState(!!options.immediately);
34+
const [internalActive, setInternalActive] = useState(active);
35+
const intervalIdRef = useRef(undefined);
36+
const done = () => {
37+
setValue(1);
38+
setInternalActive(false);
39+
setTimeout(() => setActive(false), 250);
40+
};
41+
const inc = (amount = resolveAutoIncrement(value, rate)) =>
42+
setValue((currentValue) => clamp(currentValue + amount, initialValue, maximum));
43+
const start = (from = initialValue) => {
44+
setActive(true);
45+
setInternalActive(true);
46+
setValue(from);
47+
};
48+
const remove = () => {
49+
setActive(false);
50+
setInternalActive(false);
51+
setValue(0);
52+
};
53+
useEffect(() => {
54+
if (!internalActive) return;
55+
intervalIdRef.current = setInterval(inc, speed);
56+
return () => clearInterval(intervalIdRef.current);
57+
}, [internalActive, speed, rate]);
58+
return {
59+
value,
60+
active,
61+
start,
62+
done,
63+
inc,
64+
remove
65+
};
66+
};

‎packages/core/src/hooks/time.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// timing
22
export * from './useInterval/useInterval';
3+
export * from './useProgress/useProgress';
34
export * from './useStopwatch/useStopwatch';
45
export * from './useTime/useTime';
56
export * from './useTimeout/useTimeout';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useProgress } from './useProgress';
2+
3+
const Demo = () => {
4+
const progress = useProgress(0, {
5+
speed: 250
6+
});
7+
8+
const percent = Math.round(progress.value * 100);
9+
10+
return (
11+
<div>
12+
{!!progress.active && (
13+
<div className='fixed top-0 left-0 z-[9999] h-1.5 w-full bg-sky-400/25'>
14+
<div
15+
className='h-full bg-sky-400 transition-[width] duration-200 ease-out'
16+
style={{ width: `${percent}%` }}
17+
/>
18+
</div>
19+
)}
20+
21+
<div>
22+
<p>Click to change progress status</p>
23+
<div className='flex items-center gap-2'>
24+
<button
25+
type='button'
26+
onClick={() => (progress.active ? progress.done() : progress.start())}
27+
>
28+
{progress.active ? 'Done' : 'Start'}
29+
</button>
30+
{!!progress.active && <span>{percent}%</span>}
31+
</div>
32+
</div>
33+
</div>
34+
);
35+
};
36+
37+
export default Demo;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
3+
import { renderHookServer } from '@/tests';
4+
5+
import { useProgress } from './useProgress';
6+
7+
beforeEach(vi.useFakeTimers);
8+
afterEach(vi.clearAllTimers);
9+
10+
it('Should use progress', () => {
11+
const { result } = renderHook(useProgress);
12+
13+
expect(result.current.value).toBe(0);
14+
expect(result.current.active).toBeFalsy();
15+
expect(result.current.start).toBeTypeOf('function');
16+
expect(result.current.done).toBeTypeOf('function');
17+
expect(result.current.inc).toBeTypeOf('function');
18+
expect(result.current.remove).toBeTypeOf('function');
19+
});
20+
21+
it('Should use progress on server side', () => {
22+
const { result } = renderHookServer(useProgress);
23+
24+
expect(result.current.value).toBe(0);
25+
expect(result.current.active).toBeFalsy();
26+
});
27+
28+
it('Should auto increment and never complete without done', () => {
29+
const { result } = renderHook(useProgress);
30+
31+
act(result.current.start);
32+
33+
expect(result.current.active).toBeTruthy();
34+
expect(result.current.value).toBe(0);
35+
36+
act(() => vi.advanceTimersByTime(60_000));
37+
38+
expect(result.current.value).toBeLessThan(1);
39+
expect(result.current.value).toBeGreaterThan(0.9);
40+
});
41+
42+
it('Should complete and reset after done', () => {
43+
const { result } = renderHook(() => useProgress(undefined, { speed: 200 }));
44+
45+
act(result.current.start);
46+
47+
expect(result.current.value).toBe(0);
48+
expect(result.current.active).toBeTruthy();
49+
50+
act(result.current.done);
51+
52+
expect(result.current.value).toBe(1);
53+
expect(result.current.active).toBeTruthy();
54+
55+
act(() => vi.advanceTimersByTime(250));
56+
57+
expect(result.current.active).toBeFalsy();
58+
});
59+
60+
it('Should respect maximum option', () => {
61+
const { result } = renderHook(() => useProgress(0, { maximum: 0.7 }));
62+
63+
act(result.current.start);
64+
65+
act(() => vi.advanceTimersByTime(10_000));
66+
67+
expect(result.current.value).toBeLessThanOrEqual(0.7);
68+
});
69+
70+
it('Should respect speed option', () => {
71+
const { result } = renderHook(() => useProgress(0, { speed: 1000 }));
72+
73+
act(result.current.start);
74+
75+
act(() => vi.advanceTimersByTime(999));
76+
expect(result.current.value).toBe(0);
77+
78+
act(() => vi.advanceTimersByTime(1));
79+
expect(result.current.value).toBeCloseTo(0.12, 0.13);
80+
});
81+
82+
it('Should clamp rate option', () => {
83+
const randomMock = vi.spyOn(Math, 'random').mockReturnValue(1);
84+
const { result } = renderHook(() => useProgress(0, { rate: 100, speed: 700 }));
85+
86+
act(result.current.start);
87+
88+
act(() => vi.advanceTimersByTime(700));
89+
90+
expect(result.current.value).toBeCloseTo(0.42, 0.43);
91+
92+
randomMock.mockRestore();
93+
});
94+
95+
it('Should start on mount when immediately enabled', () => {
96+
const { result } = renderHook(() => useProgress(0, { immediately: true }));
97+
98+
expect(result.current.active).toBeTruthy();
99+
100+
act(() => vi.advanceTimersByTime(1000));
101+
102+
expect(result.current.value).toBeCloseTo(0.51, 0.53);
103+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
3+
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
4+
5+
const resolveAutoIncrement = (progress: number, trickleRate: number) => {
6+
if (progress < 0.25) return 0.12 + trickleRate * Math.random();
7+
if (progress < 0.5) return 0.08 + trickleRate * Math.random();
8+
if (progress < 0.8) return 0.03 + trickleRate * Math.random();
9+
if (progress < 0.95) return 0.01 + (trickleRate / 2) * Math.random();
10+
if (progress < 0.99) return 0.005 * Math.random();
11+
return 0;
12+
};
13+
14+
/** The use progress options type */
15+
export interface UseProgressOptions {
16+
/** Start progress immediately */
17+
immediately?: boolean;
18+
/** Maximum progress value */
19+
maximum?: number;
20+
/** Additional random amount for each auto increment */
21+
rate?: number;
22+
/** Auto-increment frequency in milliseconds */
23+
speed?: number;
24+
}
25+
26+
/** The use progress return type */
27+
export interface UseProgressReturn {
28+
/** Whether progress is currently active */
29+
active: boolean;
30+
/** Current progress value in range 0..1, null means hidden */
31+
value: number;
32+
/** Complete progress to 100% */
33+
done: (force?: boolean) => number | null;
34+
/** Increment progress with easing behavior */
35+
inc: (amount?: number) => number | null;
36+
/** Remove progress and stop timers */
37+
remove: () => void;
38+
/** Start progress and auto incrementing */
39+
start: (from?: number | null) => number;
40+
}
41+
42+
/**
43+
* @name useProgress
44+
* @description - Hook that creates a lightweight progress bar state with NProgress-like behavior
45+
* @category Time
46+
* @usage medium
47+
*
48+
* @param {number} [initialProgress] Initial progress value in range 0..1
49+
* @param {boolean} [options.active] Controls progress externally (true -> start, false -> done)
50+
* @param {number} [options.maximum=0.95] Maximum value when progress starts
51+
* @param {number} [options.speed=250] Auto increment interval in milliseconds
52+
* @param {number} [options.rate=0.02] Additional random increment amount on each tick
53+
* @param {number} [options.doneResetDelay=250] Delay before reset to null after done
54+
* @returns {UseProgressReturn} Current progress state and control methods
55+
*
56+
* @example
57+
* const { value, active, start, done, inc, set, remove } = useProgress(0.2);
58+
*/
59+
export const useProgress = (initialValue: number = 0, options: UseProgressOptions = {}) => {
60+
const speed = Math.max(options.speed ?? 250, 16);
61+
const rate = clamp(options.rate ?? 0.02, 0, 0.3);
62+
const maximum = options.maximum ?? 0.98;
63+
64+
const [value, setValue] = useState(initialValue);
65+
const [active, setActive] = useState(!!options.immediately);
66+
const [internalActive, setInternalActive] = useState(active);
67+
68+
const intervalIdRef = useRef<ReturnType<typeof setInterval>>(undefined);
69+
70+
const done = () => {
71+
setValue(1);
72+
setInternalActive(false);
73+
setTimeout(() => setActive(false), 250);
74+
};
75+
76+
const inc = (amount: number = resolveAutoIncrement(value, rate)) =>
77+
setValue((currentValue) => clamp(currentValue + amount, initialValue, maximum));
78+
79+
const start = (from: number = initialValue) => {
80+
setActive(true);
81+
setInternalActive(true);
82+
setValue(from);
83+
};
84+
85+
const remove = () => {
86+
setActive(false);
87+
setInternalActive(false);
88+
setValue(0);
89+
};
90+
91+
useEffect(() => {
92+
if (!internalActive) return;
93+
intervalIdRef.current = setInterval(inc, speed);
94+
return () => clearInterval(intervalIdRef.current);
95+
}, [internalActive, speed, rate]);
96+
97+
return {
98+
value,
99+
active,
100+
start,
101+
done,
102+
inc,
103+
remove
104+
};
105+
};

0 commit comments

Comments
 (0)