{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-battery",
  "title": "useBattery",
  "description": "Reads battery level, charging state and time remaining from the Battery Status API, with a four-way support status and a low-power flag that fails open when no reading is available.",
  "files": [
    {
      "path": "src/registry/hooks/use-battery.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * Minimal structural declaration of the Battery Status API. TypeScript's `lib.dom`\n * does **not** ship `BatteryManager`, and `navigator.getBattery` is not on\n * `Navigator` — Firefox removed the spec in 52, Safari never implemented it, and\n * only Chromium still runs it. So the shape is declared locally and bridged with\n * `as unknown as`: no dependency on the consumer's lib version, and no clash with\n * declarations some third-party d.ts files ship of their own.\n */\ninterface BatteryManagerLike extends EventTarget {\n  /** Whether the device is currently charging. */\n  readonly charging: boolean\n  /** Seconds until full. `Infinity` while discharging, or when the system has no estimate. */\n  readonly chargingTime: number\n  /** Seconds until empty. `Infinity` while charging, or when the system has no estimate. */\n  readonly dischargingTime: number\n  /** Level, 0..1. Chromium quantises it to 0.01 steps to blunt fingerprinting. */\n  readonly level: number\n}\n\ninterface BatteryNavigatorLike {\n  getBattery?: () => Promise<BatteryManagerLike>\n}\n\n/**\n * Four states rather than one `isSupported` boolean — \"no reading\" has three\n * completely different causes, and each needs completely different UI wording:\n *\n * - `pending`     — no verdict yet: the server render, the first hydration render,\n *                   and the short window while the `getBattery()` promise is in flight.\n * - `ready`       — a BatteryManager is in hand; the readings below are live and\n *                   update on events.\n * - `unsupported` — this environment has no `navigator.getBattery` at all (Safari\n *                   never implemented it, Firefox dropped it in 52). This is the\n *                   **majority** outcome on today's open web.\n * - `blocked`     — the method exists but the call was refused: a cross-origin iframe\n *                   without `allow=\"battery\"`, a `Permissions-Policy: battery=()`\n *                   header, or a platform-level refusal. The reason lands in `error`.\n */\nexport type BatteryStatus = \"pending\" | \"ready\" | \"unsupported\" | \"blocked\"\n\nexport interface BatteryErrorInfo {\n  /** `DOMException.name`, most often `\"NotAllowedError\"`. */\n  name: string\n  /** The browser's own wording. Do not render it as UI copy (it is not localised), but keep it — it is what separates same-named errors. */\n  message: string\n}\n\nexport interface BatterySnapshot {\n  status: BatteryStatus\n  /** Whether this environment exposes `navigator.getBattery`. Always `false` on the server and the first hydration render. */\n  isSupported: boolean\n  /** Level, 0..1. `null` while there is no reading — **not** 0, because 0 is a legitimate reading. */\n  level: number | null\n  /** Whether the device is charging. `null` while there is no reading; do not treat it as `false`. */\n  charging: boolean | null\n  /** Seconds until full; `null` while discharging or when the system has no estimate (`Infinity` in the raw API). */\n  chargingTime: number | null\n  /** Seconds until empty; `null` while charging or when the system has no estimate (`Infinity` in the raw API). */\n  dischargingTime: number | null\n  /** Why the last `getBattery()` was refused; `null` in every other state. */\n  error: BatteryErrorInfo | null\n}\n\nexport interface UseBatteryOptions {\n  /** The \"battery is low\" threshold, a **fraction** in 0..1 (not a percentage). Defaults to `0.2`. */\n  lowThreshold?: number\n}\n\nexport interface UseBatteryResult extends BatterySnapshot {\n  /** `chargingTime` or `dischargingTime` depending on `charging`; `null` when unknown. */\n  timeRemaining: number | null\n  /**\n   * `true` only when the device is **confirmed** to be discharging at or below the\n   * threshold. `pending` / `unsupported` / `blocked` are always `false` — see the\n   * fail-open note on `useBattery`.\n   */\n  isLow: boolean\n}\n\nconst DEFAULT_LOW_THRESHOLD = 0.2\n\nconst BATTERY_EVENTS = [\n  \"chargingchange\",\n  \"chargingtimechange\",\n  \"dischargingtimechange\",\n  \"levelchange\",\n] as const\n\n/** The fallback snapshot for SSR and the first frame. One reused object constant, so `getServerSnapshot` has a stable reference. */\nconst PENDING_SNAPSHOT: BatterySnapshot = {\n  status: \"pending\",\n  isSupported: false,\n  level: null,\n  charging: null,\n  chargingTime: null,\n  dischargingTime: null,\n  error: null,\n}\n\nconst UNSUPPORTED_SNAPSHOT: BatterySnapshot = { ...PENDING_SNAPSHOT, status: \"unsupported\" }\n\n// Module-level singleton store. `getBattery()` is itself a **per-document singleton**\n// (repeated calls in one document hand back the same BatteryManager), so calling it once\n// per component is pure waste: N promise chains and N×4 listeners. This ref-counts a single\n// subscription instead, and every instance reads the same snapshot.\nlet currentSnapshot: BatterySnapshot = PENDING_SNAPSHOT\nlet manager: BatteryManagerLike | null = null\nlet inFlight: Promise<void> | null = null\nlet attached = false\nconst subscribers = new Set<() => void>()\n\nconst getSnapshot = () => currentSnapshot\nconst getServerSnapshot = () => PENDING_SNAPSHOT\n\nfunction toErrorInfo(error: unknown): BatteryErrorInfo {\n  if (error instanceof Error) return { name: error.name, message: error.message }\n  return { name: \"UnknownError\", message: String(error) }\n}\n\n/**\n * `Infinity` is how this API says \"unknown / not applicable\": `dischargingTime` is\n * always `Infinity` while charging and vice versa, and both can be `Infinity` before\n * the system has an estimate. Normalising to `null` also fixes a trap — `Infinity`\n * does not survive `JSON.stringify` (it becomes `null` or throws), so passing it\n * through poisons any consumer that persists the reading.\n */\nfunction toSeconds(value: number): number | null {\n  return Number.isFinite(value) ? value : null\n}\n\nfunction readManager(source: BatteryManagerLike): BatterySnapshot {\n  return {\n    status: \"ready\",\n    isSupported: true,\n    level: source.level,\n    charging: source.charging,\n    chargingTime: toSeconds(source.chargingTime),\n    dischargingTime: toSeconds(source.dischargingTime),\n    error: null,\n  }\n}\n\nfunction blockedSnapshot(error: BatteryErrorInfo): BatterySnapshot {\n  return { ...PENDING_SNAPSHOT, status: \"blocked\", isSupported: true, error }\n}\n\nfunction isSameSnapshot(a: BatterySnapshot, b: BatterySnapshot) {\n  return (\n    a.status === b.status &&\n    a.isSupported === b.isSupported &&\n    a.level === b.level &&\n    a.charging === b.charging &&\n    a.chargingTime === b.chargingTime &&\n    a.dischargingTime === b.dischargingTime &&\n    a.error === b.error\n  )\n}\n\n/**\n * Write a snapshot. Swap the object and broadcast only when a value really changed —\n * `useSyncExternalStore` calls `getSnapshot` during render, and returning a fresh object\n * every time reads to React as \"the store keeps changing\", which renders forever; events\n * like `chargingtimechange` routinely fire again with identical numbers.\n *\n * `notify: false` updates the cache without broadcasting and is **only** allowed inside\n * `subscribe`'s synchronous call stack: React re-reads the snapshot right after subscribing,\n * and the only instance that can reach this path is the one that took the subscriber count\n * from 0 to 1, so nobody else can be left behind.\n */\nfunction commit(next: BatterySnapshot, notify: boolean) {\n  if (isSameSnapshot(currentSnapshot, next)) return\n  currentSnapshot = next\n  if (!notify) return\n  // Iterate a copy: unsubscribing inside a listener (a component unmounting in the\n  // callback) then cannot disturb this pass.\n  for (const onStoreChange of [...subscribers]) onStoreChange()\n}\n\nfunction handleBatteryChange() {\n  if (!manager) return\n  commit(readManager(manager), true)\n}\n\nfunction attachListeners(target: BatteryManagerLike) {\n  if (attached) return\n  attached = true\n  for (const type of BATTERY_EVENTS) target.addEventListener(type, handleBatteryChange)\n}\n\nfunction detachListeners() {\n  if (!attached || !manager) return\n  attached = false\n  for (const type of BATTERY_EVENTS) manager.removeEventListener(type, handleBatteryChange)\n}\n\nfunction start() {\n  if (manager) {\n    if (attached) return\n    // Subscribed before. BatteryManager is a per-document singleton, so keep reusing it;\n    // but nothing reported the changes that happened while there were no subscribers, so\n    // re-attaching listeners has to resync the reading once.\n    attachListeners(manager)\n    commit(readManager(manager), false)\n    return\n  }\n  if (inFlight) return\n\n  const nav = typeof navigator === \"undefined\" ? null : (navigator as unknown as BatteryNavigatorLike)\n  if (!nav || typeof nav.getBattery !== \"function\") {\n    commit(UNSUPPORTED_SNAPSHOT, false)\n    return\n  }\n\n  // The API is there, the reading is not: flip `isSupported` now, keep `status` at `pending`.\n  // Feature detection happens only here (subscribe, which React calls from an effect); render\n  // never touches navigator, or the server-without-navigator and client-with-navigator would\n  // mismatch on the first frame.\n  commit({ ...PENDING_SNAPSHOT, isSupported: true }, false)\n\n  let request: Promise<BatteryManagerLike>\n  try {\n    // A few environments (older Chromium behind a permissions policy) throw **synchronously**\n    // instead of rejecting.\n    request = nav.getBattery()\n  } catch (error) {\n    commit(blockedSnapshot(toErrorInfo(error)), false)\n    return\n  }\n\n  inFlight = request.then(\n    next => {\n      inFlight = null\n      manager = next\n      // Every subscriber may be gone by the time the promise settles (mounted, then\n      // unmounted). Record the reading without attaching listeners; the next subscriber\n      // takes the reuse branch above and attaches them.\n      if (subscribers.size > 0) attachListeners(next)\n      commit(readManager(next), true)\n    },\n    (error: unknown) => {\n      inFlight = null\n      commit(blockedSnapshot(toErrorInfo(error)), true)\n    },\n  )\n}\n\n/** A module-level function, so its identity never changes — React will never re-subscribe over it. */\nfunction subscribe(onStoreChange: () => void) {\n  subscribers.add(onStoreChange)\n  start()\n  return () => {\n    subscribers.delete(onStoreChange)\n    // Detach only when the last subscriber leaves (the manager stays for reuse). Without\n    // this, unmounting any battery indicator on the page leaves four listeners attached to\n    // the BatteryManager, which lives as long as the document.\n    if (subscribers.size === 0) detachListeners()\n  }\n}\n\n/**\n * Derive the computed fields from a snapshot plus a threshold. `useBattery` runs on this\n * too; it is exported so consumers can render the same UI from a **hand-written snapshot** —\n * the states your own browser cannot produce (unsupported / blocked / 8% and discharging)\n * are exactly the ones that most need designing and testing.\n */\nexport function deriveBatteryReading(\n  snapshot: BatterySnapshot,\n  lowThreshold: number = DEFAULT_LOW_THRESHOLD,\n): UseBatteryResult {\n  const timeRemaining =\n    snapshot.charging === null\n      ? null\n      : snapshot.charging\n        ? snapshot.chargingTime\n        : snapshot.dischargingTime\n\n  return {\n    ...snapshot,\n    timeRemaining,\n    // fail-open: low only when we definitely know the device is discharging and the level\n    // is at or under the line. A null charging (no reading yet / unsupported / blocked) is\n    // always false.\n    isLow: snapshot.charging === false && snapshot.level !== null && snapshot.level <= lowThreshold,\n  }\n}\n\n/**\n * Read the device's battery level, charging state and time remaining (Battery Status API),\n * so optional work can be shed on devices that are **genuinely running down**.\n *\n * Three things matter here, and each is a trap you fall into with a bare\n * `navigator.getBattery()`:\n *\n * 1. **Most browsers give no reading at all, so \"no reading\" has to be a first-class\n *    state.** Safari never implemented it, Firefox removed it in 52, and cross-origin\n *    iframes plus `Permissions-Policy: battery=()` block Chromium too. Hence four states\n *    (`pending` / `ready` / `unsupported` / `blocked`) instead of one boolean that conflates\n *    \"haven't asked yet\" with \"can't ask\".\n * 2. **fail-open is non-negotiable.** `isLow` is true only when the device is **confirmed\n *    discharging** and `level <= lowThreshold`; anything uncertain is `false`. It exists to\n *    **defer** optional work (background sync, prefetch, autoplay, decorative motion), **not**\n *    to disable something the user asked for — otherwise the same button greys out in Chrome,\n *    never greys out in Safari, and you cannot reproduce it in Safari at all.\n * 3. **`Infinity` is everywhere.** `dischargingTime` is always `Infinity` while charging and\n *    vice versa; everything is normalised to `null` (which also makes readings JSON-safe).\n *\n * The implementation is `useSyncExternalStore` over a ref-counted module-level store: one\n * `getBattery()` call per page, one set of four event listeners, all removed when the last\n * consumer unmounts. Render touches neither `navigator` nor the clock, and the server\n * snapshot is always `pending`, so there is no hydration mismatch.\n *\n * **Honest limits**: a desktop with no battery reports `level: 1, charging: true,\n * chargingTime: 0` in Chromium — **indistinguishable** from a plugged-in, fully-charged\n * laptop. That is not a bug, it is what the spec allows. Chromium also quantises `level` to\n * 1% steps and rounds time estimates coarsely, so do not build precise billing or countdowns\n * on these numbers. Finally: level plus charging state was used as a cross-site fingerprinting\n * signal (the direct reason the spec was pulled), so **keep it out of your analytics**.\n */\nexport function useBattery(options: UseBatteryOptions = {}): UseBatteryResult {\n  const { lowThreshold = DEFAULT_LOW_THRESHOLD } = options\n  const snapshot = React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)\n  // The store shallow-compares, so snapshot identity only changes when a reading changes;\n  // memoising on top keeps the returned object's identity stable too — consumers can drop the\n  // whole reading into a dependency array to gate an effect.\n  return React.useMemo(() => deriveBatteryReading(snapshot, lowThreshold), [snapshot, lowThreshold])\n}\n\nexport default useBattery\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}