Skip to content

Commit 243087f

Browse files
feat: add useBattery hook
Implements useBattery hook that tracks device battery state using the Battery Status API. - Returns isSupported, fetched, charging, chargingTime, dischargingTime, level - Subscribes to battery change events (chargingchange, chargingtimechange, dischargingtimechange, levelchange) - SSR-safe with proper cleanup on unmount - Includes DOM and SSR tests Closes #33 (partial - sensor hooks)
1 parent c0d7c43 commit 243087f

File tree

6 files changed

+326
-1
lines changed

6 files changed

+326
-1
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export * from './useThrottledState/index.js';
4141
export * from './useValidator/index.js';
4242

4343
// Navigator
44+
export * from './useBattery/index.js';
4445
export * from './useNetworkState/index.js';
4546
export * from './usePermission/index.js';
4647
export * from './useVibrate/index.js';

src/useBattery/index.dom.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {act, renderHook} from '@ver0/react-hooks-testing';
2+
import type {vi} from 'vitest';
3+
import {describe, expect, it, beforeEach} from 'vitest';
4+
import {useBattery} from '../index.js';
5+
import {expectResultValue} from '../util/testing/test-helpers.js';
6+
7+
type MockFn = ReturnType<typeof vi.fn>;
8+
9+
type BatteryManager = {
10+
charging: boolean;
11+
chargingTime: number;
12+
dischargingTime: number;
13+
level: number;
14+
addEventListener: MockFn;
15+
removeEventListener: MockFn;
16+
};
17+
18+
describe('useBattery', () => {
19+
// Use the global getBattery mock that's already set up in the test environment
20+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
21+
const getBatteryMock = globalThis.navigator.getBattery as MockFn;
22+
23+
// Access the mock battery object from the getBattery mock
24+
let mockBattery: BatteryManager;
25+
26+
beforeEach(async () => {
27+
getBatteryMock.mockClear();
28+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
29+
mockBattery = await getBatteryMock();
30+
mockBattery.addEventListener.mockClear();
31+
mockBattery.removeEventListener.mockClear();
32+
});
33+
34+
it('should be defined', () => {
35+
expect(useBattery).toBeDefined();
36+
});
37+
38+
it('should render', async () => {
39+
const {result} = await renderHook(() => useBattery());
40+
expectResultValue(result);
41+
});
42+
43+
it('should return an object of certain structure', async () => {
44+
const {result} = await renderHook(() => useBattery());
45+
const value = expectResultValue(result);
46+
47+
expect(typeof value).toBe('object');
48+
expect(Object.keys(value)).toEqual([
49+
'isSupported',
50+
'fetched',
51+
'charging',
52+
'chargingTime',
53+
'dischargingTime',
54+
'level',
55+
]);
56+
});
57+
58+
it('should return isSupported: true when API is available', async () => {
59+
const {result} = await renderHook(() => useBattery());
60+
const value = expectResultValue(result);
61+
expect(value.isSupported).toBe(true);
62+
});
63+
64+
it('should fetch battery state when API is supported', async () => {
65+
const {result} = await renderHook(() => useBattery());
66+
67+
await act(async () => {
68+
await Promise.resolve();
69+
});
70+
71+
const value = expectResultValue(result);
72+
expect(value.fetched).toBe(true);
73+
expect(value.charging).toBe(true);
74+
expect(value.chargingTime).toBe(3600);
75+
expect(value.dischargingTime).toBe(Infinity);
76+
expect(value.level).toBe(0.75);
77+
});
78+
79+
it('should subscribe to battery events', async () => {
80+
await renderHook(() => useBattery());
81+
82+
await act(async () => {
83+
await Promise.resolve();
84+
});
85+
86+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function));
87+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function));
88+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function));
89+
expect(mockBattery.addEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function));
90+
});
91+
92+
it('should unsubscribe from battery events on unmount', async () => {
93+
const {unmount} = await renderHook(() => useBattery());
94+
95+
await act(async () => {
96+
await Promise.resolve();
97+
});
98+
99+
await unmount();
100+
101+
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingchange', expect.any(Function));
102+
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('chargingtimechange', expect.any(Function));
103+
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('dischargingtimechange', expect.any(Function));
104+
expect(mockBattery.removeEventListener).toHaveBeenCalledWith('levelchange', expect.any(Function));
105+
});
106+
107+
it('should update state when battery events fire', async () => {
108+
const {result} = await renderHook(() => useBattery());
109+
110+
await act(async () => {
111+
await Promise.resolve();
112+
});
113+
114+
let value = expectResultValue(result);
115+
expect(value.level).toBe(0.75);
116+
117+
// Simulate battery level change
118+
mockBattery.level = 0.5;
119+
120+
// Get the handler that was registered for levelchange
121+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
122+
const levelChangeHandler = mockBattery.addEventListener.mock.calls.find(
123+
(call) => call[0] === 'levelchange',
124+
)?.[1] as () => void;
125+
126+
await act(async () => {
127+
levelChangeHandler();
128+
});
129+
130+
value = expectResultValue(result);
131+
expect(value.level).toBe(0.5);
132+
});
133+
});

src/useBattery/index.ssr.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import {renderHookServer as renderHook} from '@ver0/react-hooks-testing';
2+
import {describe, expect, it} from 'vitest';
3+
import {useBattery} from '../index.js';
4+
5+
describe('useBattery', () => {
6+
it('should be defined', () => {
7+
expect(useBattery).toBeDefined();
8+
});
9+
10+
it('should render', async () => {
11+
const {result} = await renderHook(() => useBattery());
12+
expect(result.error).toBeUndefined();
13+
});
14+
15+
it('should return isSupported as false in SSR', async () => {
16+
const {result} = await renderHook(() => useBattery());
17+
expect(result.value?.isSupported).toBe(false);
18+
});
19+
20+
it('should return fetched as false in SSR', async () => {
21+
const {result} = await renderHook(() => useBattery());
22+
expect(result.value?.fetched).toBe(false);
23+
});
24+
25+
it('should return undefined values in SSR', async () => {
26+
const {result} = await renderHook(() => useBattery());
27+
expect(result.value?.charging).toBeUndefined();
28+
expect(result.value?.chargingTime).toBeUndefined();
29+
expect(result.value?.dischargingTime).toBeUndefined();
30+
expect(result.value?.level).toBeUndefined();
31+
});
32+
});

src/useBattery/index.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {useEffect, useState} from 'react';
2+
import {isBrowser} from '../util/const.js';
3+
import {off, on} from '../util/misc.js';
4+
5+
export type BatteryState = {
6+
/**
7+
* Whether the battery is currently being charged.
8+
*/
9+
charging: boolean | undefined;
10+
/**
11+
* Time in seconds until the battery is fully charged, or Infinity if not charging.
12+
*/
13+
chargingTime: number | undefined;
14+
/**
15+
* Time in seconds until the battery is fully discharged, or Infinity if charging.
16+
*/
17+
dischargingTime: number | undefined;
18+
/**
19+
* Battery charge level between 0 and 1.
20+
*/
21+
level: number | undefined;
22+
};
23+
24+
export type UseBatteryState = BatteryState & {
25+
/**
26+
* Whether the Battery Status API is supported by the browser.
27+
*/
28+
isSupported: boolean;
29+
/**
30+
* Whether the battery state is currently being fetched.
31+
*/
32+
fetched: boolean;
33+
};
34+
35+
type BatteryManager = {
36+
charging: boolean;
37+
chargingTime: number;
38+
dischargingTime: number;
39+
level: number;
40+
} & EventTarget;
41+
42+
type NavigatorWithBattery = Navigator & {
43+
getBattery?: () => Promise<BatteryManager>;
44+
};
45+
46+
const nav = isBrowser ? (globalThis.navigator as NavigatorWithBattery) : undefined;
47+
const isSupported = Boolean(nav?.getBattery);
48+
49+
function getBatteryState(battery: BatteryManager | null): UseBatteryState {
50+
if (!battery) {
51+
return {
52+
isSupported,
53+
fetched: false,
54+
charging: undefined,
55+
chargingTime: undefined,
56+
dischargingTime: undefined,
57+
level: undefined,
58+
};
59+
}
60+
61+
return {
62+
isSupported,
63+
fetched: true,
64+
charging: battery.charging,
65+
chargingTime: battery.chargingTime,
66+
dischargingTime: battery.dischargingTime,
67+
level: battery.level,
68+
};
69+
}
70+
71+
/**
72+
* Tracks the state of device's battery.
73+
*
74+
* @returns An object containing the battery state and whether the API is supported.
75+
*
76+
* @example
77+
* const { isSupported, level, charging } = useBattery();
78+
*
79+
* if (!isSupported) {
80+
* return <p>Battery API not supported</p>;
81+
* }
82+
*
83+
* return (
84+
* <p>
85+
* Battery level: {level ? `${Math.round(level * 100)}%` : 'Unknown'}
86+
* {charging && ' (Charging)'}
87+
* </p>
88+
* );
89+
*/
90+
export function useBattery(): UseBatteryState {
91+
const [state, setState] = useState<UseBatteryState>(() => getBatteryState(null));
92+
93+
useEffect(() => {
94+
if (!isSupported || !nav?.getBattery) {
95+
return;
96+
}
97+
98+
let battery: BatteryManager | null = null;
99+
100+
const handleChange = () => {
101+
if (battery) {
102+
setState(getBatteryState(battery));
103+
}
104+
};
105+
106+
// eslint-disable-next-line @typescript-eslint/no-floating-promises,promise/catch-or-return,promise/prefer-await-to-then,promise/always-return
107+
nav.getBattery().then((b) => {
108+
battery = b;
109+
setState(getBatteryState(battery));
110+
111+
on(battery, 'chargingchange', handleChange);
112+
on(battery, 'chargingtimechange', handleChange);
113+
on(battery, 'dischargingtimechange', handleChange);
114+
on(battery, 'levelchange', handleChange);
115+
});
116+
117+
return () => {
118+
if (battery) {
119+
off(battery, 'chargingchange', handleChange);
120+
off(battery, 'chargingtimechange', handleChange);
121+
off(battery, 'dischargingtimechange', handleChange);
122+
off(battery, 'levelchange', handleChange);
123+
}
124+
};
125+
}, []);
126+
127+
return state;
128+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {vi} from 'vitest';
2+
3+
type BatteryManager = {
4+
charging: boolean;
5+
chargingTime: number;
6+
dischargingTime: number;
7+
level: number;
8+
addEventListener: ReturnType<typeof vi.fn>;
9+
removeEventListener: ReturnType<typeof vi.fn>;
10+
};
11+
12+
const mockBattery: BatteryManager = {
13+
charging: true,
14+
chargingTime: 3600,
15+
dischargingTime: Infinity,
16+
level: 0.75,
17+
addEventListener: vi.fn(),
18+
removeEventListener: vi.fn(),
19+
};
20+
21+
const getBatteryMock = vi.fn<() => Promise<BatteryManager>>(async () => mockBattery);
22+
23+
Object.defineProperty(globalThis.navigator, 'getBattery', {
24+
value: getBatteryMock,
25+
writable: true,
26+
configurable: true,
27+
});

vitest.config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {defineConfig} from 'vitest/config';
33
export default defineConfig({
44
test: {
55
dir: './src',
6-
setupFiles: ['./src/util/testing/setup/react-hooks.test.ts', './src/util/testing/setup/vibrate.test.ts'],
6+
setupFiles: [
7+
'./src/util/testing/setup/react-hooks.test.ts',
8+
'./src/util/testing/setup/vibrate.test.ts',
9+
'./src/util/testing/setup/battery.test.ts',
10+
],
711
passWithNoTests: true,
812
projects: [
913
{

0 commit comments

Comments
 (0)