diff --git a/.changeset/fast-buses-push.md b/.changeset/fast-buses-push.md new file mode 100644 index 0000000..a576330 --- /dev/null +++ b/.changeset/fast-buses-push.md @@ -0,0 +1,5 @@ +--- +"tiny-hooks": minor +--- + +Add new `useBatteryStatus` hook diff --git a/src/index.ts b/src/index.ts index 8ce0bde..968c635 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { useBatteryStatus } from "./useBatteryStatus"; export { useBoolean } from "./useBoolean"; export { useBrowserCapabilities } from "./useBrowserCapabilities"; export { useClickAnywhere } from "./useClickAnywhere"; diff --git a/src/useBatteryStatus/index.ts b/src/useBatteryStatus/index.ts new file mode 100644 index 0000000..c624072 --- /dev/null +++ b/src/useBatteryStatus/index.ts @@ -0,0 +1,2 @@ +export type { BatteryStatus } from "./types"; +export { useBatteryStatus } from "./useBatteryStatus"; diff --git a/src/useBatteryStatus/types.ts b/src/useBatteryStatus/types.ts new file mode 100644 index 0000000..2d2f6b6 --- /dev/null +++ b/src/useBatteryStatus/types.ts @@ -0,0 +1,7 @@ +export interface BatteryStatus { + supported: boolean; + charging?: boolean; + level?: number; + chargingTime?: number; + dischargingTime?: number; +} diff --git a/src/useBatteryStatus/useBatteryStatus.test.ts b/src/useBatteryStatus/useBatteryStatus.test.ts new file mode 100644 index 0000000..582cc4f --- /dev/null +++ b/src/useBatteryStatus/useBatteryStatus.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "bun:test"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useBatteryStatus } from "./useBatteryStatus"; + +const createMockBattery = (overrides = {}) => ({ + charging: true, + level: 0.85, + chargingTime: 1200, + dischargingTime: 3600, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + ...overrides, +}); + +describe("useBatteryStatus", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("should return supported=false if Battery API not available", async () => { + const originalNavigator = global.navigator; + // @ts-expect-error + globalThis.navigator = {}; + + const { result } = renderHook(() => useBatteryStatus()); + + await waitFor(() => { + expect(result.current.supported).toBe(false); + }); + + globalThis.navigator = originalNavigator; + }); + + it("should return initial battery data if Battery API is available", async () => { + const mockBattery = createMockBattery(); + // @ts-expect-error + global.navigator.getBattery = vi.fn().mockResolvedValue(mockBattery); + + const { result } = renderHook(() => useBatteryStatus()); + + await waitFor(() => { + expect(result.current.supported).toBe(true); + expect(result.current.charging).toBe(true); + expect(result.current.level).toBe(0.85); + expect(result.current.chargingTime).toBe(1200); + expect(result.current.dischargingTime).toBe(3600); + }); + }); + + it("should update state when battery events fire", async () => { + let changeHandler: (() => void) | undefined; + + const mockBattery = createMockBattery({ + addEventListener: vi.fn((event, handler) => { + if (event === "levelchange") changeHandler = handler; + }), + }); + // @ts-expect-error + global.navigator.getBattery = vi.fn().mockResolvedValue(mockBattery); + + const { result } = renderHook(() => useBatteryStatus()); + + await waitFor(() => expect(result.current.supported).toBe(true)); + + mockBattery.level = 0.5; + + act(() => { + changeHandler?.(); + }); + + await waitFor(() => { + expect(result.current.level).toBe(0.5); + }); + }); +}); diff --git a/src/useBatteryStatus/useBatteryStatus.ts b/src/useBatteryStatus/useBatteryStatus.ts new file mode 100644 index 0000000..1a8b75e --- /dev/null +++ b/src/useBatteryStatus/useBatteryStatus.ts @@ -0,0 +1,68 @@ +import { useEffect, useState } from "react"; +import { assertClient } from "../utils/assertClient.ts"; +import type { BatteryStatus } from "./types.ts"; + +export function useBatteryStatus(): BatteryStatus { + assertClient(); + const [battery, setBattery] = useState({ + supported: true, + charging: undefined, + level: undefined, + chargingTime: undefined, + dischargingTime: undefined, + }); + + useEffect(() => { + // biome-ignore lint/suspicious/noExplicitAny: TS ist dumb + let batteryManager: any; + + const handleChange = () => { + if (batteryManager) { + setBattery({ + supported: true, + charging: batteryManager.charging, + level: batteryManager.level, + chargingTime: batteryManager.chargingTime, + dischargingTime: batteryManager.dischargingTime, + }); + } + }; + + const setup = async () => { + try { + // biome-ignore lint/suspicious/noExplicitAny: TS ist dumb + const nav = navigator as any; + if (typeof nav.getBattery !== "function") { + setBattery({ supported: false }); + return; + } + + batteryManager = await nav.getBattery(); + handleChange(); + + batteryManager.addEventListener("chargingchange", handleChange); + batteryManager.addEventListener("levelchange", handleChange); + batteryManager.addEventListener("chargingtimechange", handleChange); + batteryManager.addEventListener("dischargingtimechange", handleChange); + } catch { + setBattery({ supported: false }); + } + }; + + setup(); + + return () => { + if (batteryManager) { + batteryManager.removeEventListener("chargingchange", handleChange); + batteryManager.removeEventListener("levelchange", handleChange); + batteryManager.removeEventListener("chargingtimechange", handleChange); + batteryManager.removeEventListener( + "dischargingtimechange", + handleChange, + ); + } + }; + }, []); + + return battery; +}