preact-missing-hooks
Version:
A lightweight, extendable collection of missing React-like hooks for Preact — plus fresh, powerful new ones designed specifically for modern Preact apps.
479 lines (426 loc) • 13.8 kB
text/typescript
import { useCallback, useEffect, useState } from "preact/hooks";
/** Parsed Client Hints from `navigator.userAgentData` when available */
export interface UserAgentDataInfo {
mobile: boolean;
platform: string;
brands: ReadonlyArray<{ brand: string; version: string }>;
}
/** Screen and display metrics from `screen` and `window` */
export interface DeviceScreenInfo {
width: number;
height: number;
availWidth: number;
availHeight: number;
colorDepth: number;
pixelRatio: number;
}
/** Viewport size (`window.innerWidth` / `innerHeight`) */
export interface DeviceViewportInfo {
width: number;
height: number;
}
/** Battery status from the Battery Status API when available */
export interface DeviceBatteryInfo {
charging: boolean;
level: number;
}
/** Detected browser name and version */
export interface DeviceBrowserInfo {
name: string;
version: string;
}
/** Detected operating system name and version */
export interface DeviceOsInfo {
name: string;
version: string;
}
/** Snapshot of device / browser data from native Navigator and related APIs */
export interface DeviceData {
userAgent: string;
language: string;
languages: readonly string[];
platform: string;
/** Detected browser (from Client Hints or user-agent parsing) */
browser: DeviceBrowserInfo;
/** Detected OS (from Client Hints or user-agent parsing) */
os: DeviceOsInfo;
cookieEnabled: boolean;
online: boolean;
hardwareConcurrency?: number;
/** Approximate device RAM in GB (Chrome / some browsers only) */
deviceMemory?: number;
maxTouchPoints: number;
vendor: string;
touch: boolean;
screen: DeviceScreenInfo;
viewport: DeviceViewportInfo;
userAgentData?: UserAgentDataInfo;
reducedMotion: boolean;
colorScheme: "light" | "dark" | "no-preference";
battery?: DeviceBatteryInfo;
}
export interface UseDeviceDataOptions {
/** Fetch battery info when the Battery Status API exists (default: true) */
includeBattery?: boolean;
/** Battery refresh interval in ms (default: 60000) */
batteryPollIntervalMs?: number;
/**
* Request high-entropy Client Hints (`platformVersion`, `fullVersionList`)
* when `navigator.userAgentData` supports it (default: true)
*/
includeHighEntropy?: boolean;
}
interface NavigatorUAData {
mobile?: boolean;
platform?: string;
brands?: Array<{ brand: string; version: string }>;
getHighEntropyValues?: (hints: string[]) => Promise<{
platformVersion?: string;
fullVersionList?: Array<{ brand: string; version: string }>;
}>;
}
type NavigatorWithExtras = Navigator & {
deviceMemory?: number;
userAgentData?: NavigatorUAData;
};
const UNKNOWN_BROWSER: DeviceBrowserInfo = { name: "Unknown", version: "" };
const UNKNOWN_OS: DeviceOsInfo = { name: "Unknown", version: "" };
const SSR_DEVICE_DATA: DeviceData = {
userAgent: "",
language: "en",
languages: ["en"],
platform: "",
browser: UNKNOWN_BROWSER,
os: UNKNOWN_OS,
cookieEnabled: false,
online: true,
maxTouchPoints: 0,
vendor: "",
touch: false,
screen: {
width: 0,
height: 0,
availWidth: 0,
availHeight: 0,
colorDepth: 24,
pixelRatio: 1,
},
viewport: { width: 0, height: 0 },
reducedMotion: false,
colorScheme: "no-preference",
};
const NOT_A_BRAND = /not.?a.?brand/i;
/** Parse browser and OS from a user-agent string (sync fallback). */
export function parseUserAgent(userAgent: string): {
browser: DeviceBrowserInfo;
os: DeviceOsInfo;
} {
const browser: DeviceBrowserInfo = { ...UNKNOWN_BROWSER };
const os: DeviceOsInfo = { ...UNKNOWN_OS };
if (!userAgent) {
return { browser, os };
}
const win = userAgent.match(/Windows NT ([\d.]+)/);
if (win) {
os.name = "Windows";
os.version = win[1];
} else {
const mac = userAgent.match(/Mac OS X ([\d._]+)/);
if (mac) {
os.name = "macOS";
os.version = mac[1].replace(/_/g, ".");
} else {
const android = userAgent.match(/Android ([\d.]+)/);
if (android) {
os.name = "Android";
os.version = android[1];
} else {
const ios = userAgent.match(/(?:iPhone OS|CPU OS) ([\d_]+)/);
if (ios) {
os.name = "iOS";
os.version = ios[1].replace(/_/g, ".");
} else if (/Linux/.test(userAgent)) {
os.name = "Linux";
const linux = userAgent.match(/Linux ([\d.]+)/);
os.version = linux?.[1] ?? "";
}
}
}
}
const edg = userAgent.match(/Edg(?:A|iOS)?\/([\d.]+)/);
const opera = userAgent.match(/OPR\/([\d.]+)/);
const firefox = userAgent.match(/Firefox\/([\d.]+)/);
const safari =
/Version\/([\d.]+)/.test(userAgent) &&
/Safari/.test(userAgent) &&
!/Chrome|Chromium|Edg|OPR/.test(userAgent)
? userAgent.match(/Version\/([\d.]+)/)
: null;
const chrome = userAgent.match(/Chrome\/([\d.]+)/);
if (edg) {
browser.name = "Edge";
browser.version = edg[1];
} else if (opera) {
browser.name = "Opera";
browser.version = opera[1];
} else if (firefox) {
browser.name = "Firefox";
browser.version = firefox[1];
} else if (safari) {
browser.name = "Safari";
browser.version = safari[1];
} else if (chrome) {
browser.name = "Chrome";
browser.version = chrome[1];
}
return { browser, os };
}
function pickBrowserBrand(
brands: Array<{ brand: string; version: string }>
): DeviceBrowserInfo {
const meaningful = brands.find((b) => !NOT_A_BRAND.test(b.brand));
const pick = meaningful ?? brands[0];
if (!pick) return { ...UNKNOWN_BROWSER };
return { name: pick.brand, version: pick.version };
}
function mergeBrowserOsFromUaData(
uaData: NavigatorUAData,
fallback: { browser: DeviceBrowserInfo; os: DeviceOsInfo }
): { browser: DeviceBrowserInfo; os: DeviceOsInfo } {
const browser = uaData.brands?.length
? pickBrowserBrand(uaData.brands)
: { ...fallback.browser };
const os: DeviceOsInfo = {
name: uaData.platform?.trim() || fallback.os.name,
version: fallback.os.version,
};
return { browser, os };
}
function getColorScheme(): DeviceData["colorScheme"] {
if (typeof window === "undefined" || !window.matchMedia) {
return "no-preference";
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
if (window.matchMedia("(prefers-color-scheme: light)").matches) {
return "light";
}
return "no-preference";
}
function getReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) {
return false;
}
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/** Reads synchronous device / browser data from Navigator, Screen, and matchMedia. */
export function getDeviceData(): DeviceData {
if (typeof navigator === "undefined") {
return SSR_DEVICE_DATA;
}
const nav = navigator as NavigatorWithExtras;
const screen =
typeof globalThis.screen !== "undefined" ? globalThis.screen : null;
const win =
typeof globalThis.window !== "undefined" ? globalThis.window : null;
const ua = nav.userAgent ?? "";
const parsed = parseUserAgent(ua);
const uaData = nav.userAgentData;
const { browser, os } = uaData
? mergeBrowserOsFromUaData(uaData, parsed)
: parsed;
const data: DeviceData = {
userAgent: ua,
language: nav.language ?? "",
languages: nav.languages ? [...nav.languages] : [],
platform: nav.platform ?? "",
browser,
os,
cookieEnabled: Boolean(nav.cookieEnabled),
online: Boolean(nav.onLine),
maxTouchPoints: nav.maxTouchPoints ?? 0,
vendor: nav.vendor ?? "",
touch: (nav.maxTouchPoints ?? 0) > 0,
screen: {
width: screen?.width ?? 0,
height: screen?.height ?? 0,
availWidth: screen?.availWidth ?? 0,
availHeight: screen?.availHeight ?? 0,
colorDepth: screen?.colorDepth ?? 24,
pixelRatio: win?.devicePixelRatio ?? 1,
},
viewport: {
width: win?.innerWidth ?? 0,
height: win?.innerHeight ?? 0,
},
reducedMotion: getReducedMotion(),
colorScheme: getColorScheme(),
};
if (typeof nav.hardwareConcurrency === "number") {
data.hardwareConcurrency = nav.hardwareConcurrency;
}
if (typeof nav.deviceMemory === "number") {
data.deviceMemory = nav.deviceMemory;
}
if (uaData) {
data.userAgentData = {
mobile: Boolean(uaData.mobile),
platform: uaData.platform ?? "",
brands: uaData.brands ? [...uaData.brands] : [],
};
}
return data;
}
async function readHighEntropyBrowserOs(
uaData: NavigatorUAData,
current: { browser: DeviceBrowserInfo; os: DeviceOsInfo }
): Promise<{ browser: DeviceBrowserInfo; os: DeviceOsInfo } | undefined> {
if (!uaData.getHighEntropyValues) return undefined;
try {
const hints = await uaData.getHighEntropyValues([
"platformVersion",
"fullVersionList",
]);
const browser = hints.fullVersionList?.length
? pickBrowserBrand(hints.fullVersionList)
: { ...current.browser };
const os: DeviceOsInfo = {
name: current.os.name,
version: hints.platformVersion?.trim() || current.os.version,
};
return { browser, os };
} catch {
return undefined;
}
}
async function readBattery(): Promise<DeviceBatteryInfo | undefined> {
if (typeof navigator === "undefined") return undefined;
const getBattery = (
navigator as Navigator & {
getBattery?: () => Promise<{
charging: boolean;
level: number;
}>;
}
).getBattery;
if (!getBattery) return undefined;
try {
const battery = await getBattery.call(navigator);
return { charging: battery.charging, level: battery.level };
} catch {
return undefined;
}
}
/**
* Extracts device and browser data from native Navigator, Screen, window, and
* matchMedia APIs. Updates on resize, orientation, online/offline, and
* prefers-color-scheme / prefers-reduced-motion changes. Optionally polls the
* Battery Status API when available.
*
* @param options - `includeBattery` (default true), `batteryPollIntervalMs` (default 60000)
* @returns Current {@link DeviceData} snapshot
*
* @example
* ```tsx
* function DevicePanel() {
* const device = useDeviceData();
* return (
* <dl>
* <dt>Browser</dt><dd>{device.browser.name} {device.browser.version}</dd>
* <dt>OS</dt><dd>{device.os.name} {device.os.version}</dd>
* <dt>Language</dt><dd>{device.language}</dd>
* <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
* </dl>
* );
* }
* ```
*/
export function useDeviceData(options: UseDeviceDataOptions = {}): DeviceData {
const {
includeBattery = true,
batteryPollIntervalMs = 60_000,
includeHighEntropy = true,
} = options;
const [data, setData] = useState<DeviceData>(() => getDeviceData());
const refresh = useCallback(() => {
setData((prev) => {
const next = getDeviceData();
return {
...next,
...(prev.battery ? { battery: prev.battery } : {}),
};
});
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const onResize = () => refresh();
window.addEventListener("resize", onResize);
window.addEventListener("orientationchange", onResize);
window.addEventListener("online", refresh);
window.addEventListener("offline", refresh);
const reducedMotionMq = window.matchMedia?.(
"(prefers-reduced-motion: reduce)"
);
const darkMq = window.matchMedia?.("(prefers-color-scheme: dark)");
const lightMq = window.matchMedia?.("(prefers-color-scheme: light)");
const onMediaChange = () => refresh();
reducedMotionMq?.addEventListener?.("change", onMediaChange);
darkMq?.addEventListener?.("change", onMediaChange);
lightMq?.addEventListener?.("change", onMediaChange);
return () => {
window.removeEventListener("resize", onResize);
window.removeEventListener("orientationchange", onResize);
window.removeEventListener("online", refresh);
window.removeEventListener("offline", refresh);
reducedMotionMq?.removeEventListener?.("change", onMediaChange);
darkMq?.removeEventListener?.("change", onMediaChange);
lightMq?.removeEventListener?.("change", onMediaChange);
};
}, [refresh]);
useEffect(() => {
if (!includeHighEntropy || typeof navigator === "undefined") return;
const uaData = (navigator as NavigatorWithExtras).userAgentData;
if (!uaData?.getHighEntropyValues) return;
let cancelled = false;
const updateHighEntropy = async () => {
const base = getDeviceData();
const enriched = await readHighEntropyBrowserOs(uaData, {
browser: base.browser,
os: base.os,
});
if (cancelled || !enriched) return;
setData((prev) => ({
...prev,
browser: enriched.browser,
os: enriched.os,
}));
};
void updateHighEntropy();
return () => {
cancelled = true;
};
}, [includeHighEntropy]);
useEffect(() => {
if (!includeBattery || typeof navigator === "undefined") return;
let cancelled = false;
let intervalId: ReturnType<typeof setInterval> | undefined;
const updateBattery = async () => {
const battery = await readBattery();
if (cancelled || battery === undefined) return;
setData((prev) => ({ ...prev, battery }));
};
void updateBattery();
if (batteryPollIntervalMs > 0) {
intervalId = setInterval(
() => void updateBattery(),
batteryPollIntervalMs
);
}
return () => {
cancelled = true;
if (intervalId !== undefined) clearInterval(intervalId);
};
}, [includeBattery, batteryPollIntervalMs]);
return data;
}