diff --git a/src/index.ts b/src/index.ts index 94b995a5..a802eb2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,3 +77,5 @@ export { resolveHookState } from './util/resolveHookState'; // Types export * from './types'; + +export * from './useBattery'; diff --git a/src/useBattery/__docs__/example.stories.tsx b/src/useBattery/__docs__/example.stories.tsx new file mode 100644 index 00000000..d44665c2 --- /dev/null +++ b/src/useBattery/__docs__/example.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { useBattery } from '../..'; + +export function Example() { + const batteryStats = useBattery(); + return ( +
+
Your battery state:
+
{JSON.stringify(batteryStats, null, 2)}
+
+ ); +} diff --git a/src/useBattery/__docs__/story.mdx b/src/useBattery/__docs__/story.mdx new file mode 100644 index 00000000..91daf8c3 --- /dev/null +++ b/src/useBattery/__docs__/story.mdx @@ -0,0 +1,45 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs'; +import { Example } from './example.stories'; +import { ImportPath } from '../../__docs__/ImportPath'; + + + +# useBattery + +Hook that tracks battery state. + +- Automatically updates on battery state changes. +- SSR compatible. (Properties return `undefined` on server.) + +#### Example + + + + + +## Reference + +```ts +export type BatteryState = { + /** + * @desc {true} if the battery is charging, {false} otherwise. + */ + readonly charging: boolean | undefined; + /** + * @desc The time remaining in seconds until the system's battery is fully charged. + */ + readonly chargingTime: number | undefined; + /** + * @desc The time remaining in seconds until the system's battery is fully discharged. + */ + readonly dischargingTime: number | undefined; + /** + * @desc The battery level of the system as a number between 0 and 1. + */ + readonly level: number | undefined; +}; +``` + +#### Importing + + diff --git a/src/useBattery/__tests__/dom.ts b/src/useBattery/__tests__/dom.ts new file mode 100644 index 00000000..6328fadc --- /dev/null +++ b/src/useBattery/__tests__/dom.ts @@ -0,0 +1,24 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { useBattery } from '../..'; + +describe('useBattery', () => { + it('should be defined', () => { + expect(useBattery).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useBattery()); + expect(result.error).toBeUndefined(); + }); + it('should return an object of certain structure', () => { + const hook = renderHook(() => useBattery(), { initialProps: false }); + + expect(typeof hook.result.current).toEqual('object'); + expect(Object.keys(hook.result.current)).toEqual([ + 'charging', + 'chargingTime', + 'dischargingTime', + 'level', + ]); + }); +}); diff --git a/src/useBattery/__tests__/ssr.ts b/src/useBattery/__tests__/ssr.ts new file mode 100644 index 00000000..6731d105 --- /dev/null +++ b/src/useBattery/__tests__/ssr.ts @@ -0,0 +1,32 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useBattery } from '../..'; + +describe('useBattery', () => { + it('should be defined', () => { + expect(useBattery).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useBattery()); + expect(result.error).toBeUndefined(); + }); + it('should return an object of certain structure', () => { + const hook = renderHook(() => useBattery(), { initialProps: false }); + + expect(typeof hook.result.current).toEqual('object'); + expect(Object.keys(hook.result.current)).toEqual([ + 'charging', + 'chargingTime', + 'dischargingTime', + 'level', + ]); + }); + it('should return undefined values', () => { + const hook = renderHook(() => useBattery(), { initialProps: false }); + + expect(hook.result.current.charging).toBeUndefined(); + expect(hook.result.current.chargingTime).toBeUndefined(); + expect(hook.result.current.dischargingTime).toBeUndefined(); + expect(hook.result.current.level).toBeUndefined(); + }); +}); diff --git a/src/useBattery/index.ts b/src/useBattery/index.ts new file mode 100644 index 00000000..13543d77 --- /dev/null +++ b/src/useBattery/index.ts @@ -0,0 +1,120 @@ +import { isEqual } from '@react-hookz/deep-equal'; +import { isBrowser } from '../util/const'; +import { off, on } from '../util/misc'; +import { useEffect, useState } from 'react'; + +/** + * The BatteryState interface is the return type of the useBattery Hook. + * + * provides information about the system's battery charge level + * and whether the device is charging, discharging, or fully charged. + * + * Uses the [BatteryManager](https://developer.mozilla.org/en-US/docs/Web/API/BatteryManager) interface. + * + * In server-side rendering (SSR) environments, the returned values will be undefined. + */ +export type BatteryState = { + /** + * @desc {true} if the battery is charging, {false} otherwise. + */ + readonly charging: boolean | undefined; + /** + * @desc The time remaining in seconds until the system's battery is fully charged. + */ + readonly chargingTime: number | undefined; + /** + * @desc The time remaining in seconds until the system's battery is fully discharged. + */ + readonly dischargingTime: number | undefined; + /** + * @desc The battery level of the system as a number between 0 and 1. + */ + readonly level: number | undefined; +}; + +type BatteryManager = { + readonly charging: boolean; + readonly chargingTime: number; + readonly dischargingTime: number; + readonly level: number; +} & EventTarget; + +type NavigatorWithPossibleBattery = Navigator & { + getBattery?: () => Promise; +}; + +const nav: NavigatorWithPossibleBattery | undefined = isBrowser ? navigator : undefined; +/** + * Hook that tracks battery state. + * + * @see https://react-hookz.github.io/web/?path=/docs/navigator-usebattery + * + * @returns + * - {charging} - whether the system is charging + * - {chargingTime} - time remaining in seconds until the system is fully charged + * - {dischargingTime} - time remaining in seconds until the system is fully discharged + * - {level} - battery level in percent + * + * In server-side rendering (SSR) environments, the returned values will be undefined. + */ +export function useBattery(): BatteryState { + const [state, setState] = useState({ + charging: undefined, + chargingTime: undefined, + dischargingTime: undefined, + level: undefined, + }); + + useEffect(() => { + let isMounted = true; + let battery: BatteryManager | null = null; + + const handleChange = () => { + if (!isMounted || !battery) { + return; + } + + const newState: BatteryState = { + level: battery.level, + charging: battery.charging, + dischargingTime: battery.dischargingTime, + chargingTime: battery.chargingTime, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + !isEqual(state, newState) && setState(newState); + }; + + nav + ?.getBattery?.() + .then((bat: BatteryManager) => { + // eslint-disable-next-line promise/always-return + if (!isMounted) { + return; + } + + battery = bat; + on(battery, 'chargingchange', handleChange); + on(battery, 'chargingtimechange', handleChange); + on(battery, 'dischargingtimechange', handleChange); + on(battery, 'levelchange', handleChange); + handleChange(); + }) + .catch(() => { + // ignore + }); + + return () => { + isMounted = false; + if (battery) { + off(battery, 'chargingchange', handleChange); + off(battery, 'chargingtimechange', handleChange); + off(battery, 'dischargingtimechange', handleChange); + off(battery, 'levelchange', handleChange); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return state; +}