preact-missing-hooks 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,478 @@
1
+ import { useCallback, useEffect, useState } from "preact/hooks";
2
+
3
+ /** Parsed Client Hints from `navigator.userAgentData` when available */
4
+ export interface UserAgentDataInfo {
5
+ mobile: boolean;
6
+ platform: string;
7
+ brands: ReadonlyArray<{ brand: string; version: string }>;
8
+ }
9
+
10
+ /** Screen and display metrics from `screen` and `window` */
11
+ export interface DeviceScreenInfo {
12
+ width: number;
13
+ height: number;
14
+ availWidth: number;
15
+ availHeight: number;
16
+ colorDepth: number;
17
+ pixelRatio: number;
18
+ }
19
+
20
+ /** Viewport size (`window.innerWidth` / `innerHeight`) */
21
+ export interface DeviceViewportInfo {
22
+ width: number;
23
+ height: number;
24
+ }
25
+
26
+ /** Battery status from the Battery Status API when available */
27
+ export interface DeviceBatteryInfo {
28
+ charging: boolean;
29
+ level: number;
30
+ }
31
+
32
+ /** Detected browser name and version */
33
+ export interface DeviceBrowserInfo {
34
+ name: string;
35
+ version: string;
36
+ }
37
+
38
+ /** Detected operating system name and version */
39
+ export interface DeviceOsInfo {
40
+ name: string;
41
+ version: string;
42
+ }
43
+
44
+ /** Snapshot of device / browser data from native Navigator and related APIs */
45
+ export interface DeviceData {
46
+ userAgent: string;
47
+ language: string;
48
+ languages: readonly string[];
49
+ platform: string;
50
+ /** Detected browser (from Client Hints or user-agent parsing) */
51
+ browser: DeviceBrowserInfo;
52
+ /** Detected OS (from Client Hints or user-agent parsing) */
53
+ os: DeviceOsInfo;
54
+ cookieEnabled: boolean;
55
+ online: boolean;
56
+ hardwareConcurrency?: number;
57
+ /** Approximate device RAM in GB (Chrome / some browsers only) */
58
+ deviceMemory?: number;
59
+ maxTouchPoints: number;
60
+ vendor: string;
61
+ touch: boolean;
62
+ screen: DeviceScreenInfo;
63
+ viewport: DeviceViewportInfo;
64
+ userAgentData?: UserAgentDataInfo;
65
+ reducedMotion: boolean;
66
+ colorScheme: "light" | "dark" | "no-preference";
67
+ battery?: DeviceBatteryInfo;
68
+ }
69
+
70
+ export interface UseDeviceDataOptions {
71
+ /** Fetch battery info when the Battery Status API exists (default: true) */
72
+ includeBattery?: boolean;
73
+ /** Battery refresh interval in ms (default: 60000) */
74
+ batteryPollIntervalMs?: number;
75
+ /**
76
+ * Request high-entropy Client Hints (`platformVersion`, `fullVersionList`)
77
+ * when `navigator.userAgentData` supports it (default: true)
78
+ */
79
+ includeHighEntropy?: boolean;
80
+ }
81
+
82
+ interface NavigatorUAData {
83
+ mobile?: boolean;
84
+ platform?: string;
85
+ brands?: Array<{ brand: string; version: string }>;
86
+ getHighEntropyValues?: (hints: string[]) => Promise<{
87
+ platformVersion?: string;
88
+ fullVersionList?: Array<{ brand: string; version: string }>;
89
+ }>;
90
+ }
91
+
92
+ type NavigatorWithExtras = Navigator & {
93
+ deviceMemory?: number;
94
+ userAgentData?: NavigatorUAData;
95
+ };
96
+
97
+ const UNKNOWN_BROWSER: DeviceBrowserInfo = { name: "Unknown", version: "" };
98
+ const UNKNOWN_OS: DeviceOsInfo = { name: "Unknown", version: "" };
99
+
100
+ const SSR_DEVICE_DATA: DeviceData = {
101
+ userAgent: "",
102
+ language: "en",
103
+ languages: ["en"],
104
+ platform: "",
105
+ browser: UNKNOWN_BROWSER,
106
+ os: UNKNOWN_OS,
107
+ cookieEnabled: false,
108
+ online: true,
109
+ maxTouchPoints: 0,
110
+ vendor: "",
111
+ touch: false,
112
+ screen: {
113
+ width: 0,
114
+ height: 0,
115
+ availWidth: 0,
116
+ availHeight: 0,
117
+ colorDepth: 24,
118
+ pixelRatio: 1,
119
+ },
120
+ viewport: { width: 0, height: 0 },
121
+ reducedMotion: false,
122
+ colorScheme: "no-preference",
123
+ };
124
+
125
+ const NOT_A_BRAND = /not.?a.?brand/i;
126
+
127
+ /** Parse browser and OS from a user-agent string (sync fallback). */
128
+ export function parseUserAgent(userAgent: string): {
129
+ browser: DeviceBrowserInfo;
130
+ os: DeviceOsInfo;
131
+ } {
132
+ const browser: DeviceBrowserInfo = { ...UNKNOWN_BROWSER };
133
+ const os: DeviceOsInfo = { ...UNKNOWN_OS };
134
+
135
+ if (!userAgent) {
136
+ return { browser, os };
137
+ }
138
+
139
+ const win = userAgent.match(/Windows NT ([\d.]+)/);
140
+ if (win) {
141
+ os.name = "Windows";
142
+ os.version = win[1];
143
+ } else {
144
+ const mac = userAgent.match(/Mac OS X ([\d._]+)/);
145
+ if (mac) {
146
+ os.name = "macOS";
147
+ os.version = mac[1].replace(/_/g, ".");
148
+ } else {
149
+ const android = userAgent.match(/Android ([\d.]+)/);
150
+ if (android) {
151
+ os.name = "Android";
152
+ os.version = android[1];
153
+ } else {
154
+ const ios = userAgent.match(/(?:iPhone OS|CPU OS) ([\d_]+)/);
155
+ if (ios) {
156
+ os.name = "iOS";
157
+ os.version = ios[1].replace(/_/g, ".");
158
+ } else if (/Linux/.test(userAgent)) {
159
+ os.name = "Linux";
160
+ const linux = userAgent.match(/Linux ([\d.]+)/);
161
+ os.version = linux?.[1] ?? "";
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ const edg = userAgent.match(/Edg(?:A|iOS)?\/([\d.]+)/);
168
+ const opera = userAgent.match(/OPR\/([\d.]+)/);
169
+ const firefox = userAgent.match(/Firefox\/([\d.]+)/);
170
+ const safari =
171
+ /Version\/([\d.]+)/.test(userAgent) &&
172
+ /Safari/.test(userAgent) &&
173
+ !/Chrome|Chromium|Edg|OPR/.test(userAgent)
174
+ ? userAgent.match(/Version\/([\d.]+)/)
175
+ : null;
176
+ const chrome = userAgent.match(/Chrome\/([\d.]+)/);
177
+
178
+ if (edg) {
179
+ browser.name = "Edge";
180
+ browser.version = edg[1];
181
+ } else if (opera) {
182
+ browser.name = "Opera";
183
+ browser.version = opera[1];
184
+ } else if (firefox) {
185
+ browser.name = "Firefox";
186
+ browser.version = firefox[1];
187
+ } else if (safari) {
188
+ browser.name = "Safari";
189
+ browser.version = safari[1];
190
+ } else if (chrome) {
191
+ browser.name = "Chrome";
192
+ browser.version = chrome[1];
193
+ }
194
+
195
+ return { browser, os };
196
+ }
197
+
198
+ function pickBrowserBrand(
199
+ brands: Array<{ brand: string; version: string }>
200
+ ): DeviceBrowserInfo {
201
+ const meaningful = brands.find((b) => !NOT_A_BRAND.test(b.brand));
202
+ const pick = meaningful ?? brands[0];
203
+ if (!pick) return { ...UNKNOWN_BROWSER };
204
+ return { name: pick.brand, version: pick.version };
205
+ }
206
+
207
+ function mergeBrowserOsFromUaData(
208
+ uaData: NavigatorUAData,
209
+ fallback: { browser: DeviceBrowserInfo; os: DeviceOsInfo }
210
+ ): { browser: DeviceBrowserInfo; os: DeviceOsInfo } {
211
+ const browser = uaData.brands?.length
212
+ ? pickBrowserBrand(uaData.brands)
213
+ : { ...fallback.browser };
214
+ const os: DeviceOsInfo = {
215
+ name: uaData.platform?.trim() || fallback.os.name,
216
+ version: fallback.os.version,
217
+ };
218
+ return { browser, os };
219
+ }
220
+
221
+ function getColorScheme(): DeviceData["colorScheme"] {
222
+ if (typeof window === "undefined" || !window.matchMedia) {
223
+ return "no-preference";
224
+ }
225
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
226
+ return "dark";
227
+ }
228
+ if (window.matchMedia("(prefers-color-scheme: light)").matches) {
229
+ return "light";
230
+ }
231
+ return "no-preference";
232
+ }
233
+
234
+ function getReducedMotion(): boolean {
235
+ if (typeof window === "undefined" || !window.matchMedia) {
236
+ return false;
237
+ }
238
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
239
+ }
240
+
241
+ /** Reads synchronous device / browser data from Navigator, Screen, and matchMedia. */
242
+ export function getDeviceData(): DeviceData {
243
+ if (typeof navigator === "undefined") {
244
+ return SSR_DEVICE_DATA;
245
+ }
246
+
247
+ const nav = navigator as NavigatorWithExtras;
248
+ const screen =
249
+ typeof globalThis.screen !== "undefined" ? globalThis.screen : null;
250
+ const win =
251
+ typeof globalThis.window !== "undefined" ? globalThis.window : null;
252
+
253
+ const ua = nav.userAgent ?? "";
254
+ const parsed = parseUserAgent(ua);
255
+ const uaData = nav.userAgentData;
256
+ const { browser, os } = uaData
257
+ ? mergeBrowserOsFromUaData(uaData, parsed)
258
+ : parsed;
259
+
260
+ const data: DeviceData = {
261
+ userAgent: ua,
262
+ language: nav.language ?? "",
263
+ languages: nav.languages ? [...nav.languages] : [],
264
+ platform: nav.platform ?? "",
265
+ browser,
266
+ os,
267
+ cookieEnabled: Boolean(nav.cookieEnabled),
268
+ online: Boolean(nav.onLine),
269
+ maxTouchPoints: nav.maxTouchPoints ?? 0,
270
+ vendor: nav.vendor ?? "",
271
+ touch: (nav.maxTouchPoints ?? 0) > 0,
272
+ screen: {
273
+ width: screen?.width ?? 0,
274
+ height: screen?.height ?? 0,
275
+ availWidth: screen?.availWidth ?? 0,
276
+ availHeight: screen?.availHeight ?? 0,
277
+ colorDepth: screen?.colorDepth ?? 24,
278
+ pixelRatio: win?.devicePixelRatio ?? 1,
279
+ },
280
+ viewport: {
281
+ width: win?.innerWidth ?? 0,
282
+ height: win?.innerHeight ?? 0,
283
+ },
284
+ reducedMotion: getReducedMotion(),
285
+ colorScheme: getColorScheme(),
286
+ };
287
+
288
+ if (typeof nav.hardwareConcurrency === "number") {
289
+ data.hardwareConcurrency = nav.hardwareConcurrency;
290
+ }
291
+ if (typeof nav.deviceMemory === "number") {
292
+ data.deviceMemory = nav.deviceMemory;
293
+ }
294
+
295
+ if (uaData) {
296
+ data.userAgentData = {
297
+ mobile: Boolean(uaData.mobile),
298
+ platform: uaData.platform ?? "",
299
+ brands: uaData.brands ? [...uaData.brands] : [],
300
+ };
301
+ }
302
+
303
+ return data;
304
+ }
305
+
306
+ async function readHighEntropyBrowserOs(
307
+ uaData: NavigatorUAData,
308
+ current: { browser: DeviceBrowserInfo; os: DeviceOsInfo }
309
+ ): Promise<{ browser: DeviceBrowserInfo; os: DeviceOsInfo } | undefined> {
310
+ if (!uaData.getHighEntropyValues) return undefined;
311
+ try {
312
+ const hints = await uaData.getHighEntropyValues([
313
+ "platformVersion",
314
+ "fullVersionList",
315
+ ]);
316
+ const browser = hints.fullVersionList?.length
317
+ ? pickBrowserBrand(hints.fullVersionList)
318
+ : { ...current.browser };
319
+ const os: DeviceOsInfo = {
320
+ name: current.os.name,
321
+ version: hints.platformVersion?.trim() || current.os.version,
322
+ };
323
+ return { browser, os };
324
+ } catch {
325
+ return undefined;
326
+ }
327
+ }
328
+
329
+ async function readBattery(): Promise<DeviceBatteryInfo | undefined> {
330
+ if (typeof navigator === "undefined") return undefined;
331
+ const getBattery = (
332
+ navigator as Navigator & {
333
+ getBattery?: () => Promise<{
334
+ charging: boolean;
335
+ level: number;
336
+ }>;
337
+ }
338
+ ).getBattery;
339
+ if (!getBattery) return undefined;
340
+ try {
341
+ const battery = await getBattery.call(navigator);
342
+ return { charging: battery.charging, level: battery.level };
343
+ } catch {
344
+ return undefined;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Extracts device and browser data from native Navigator, Screen, window, and
350
+ * matchMedia APIs. Updates on resize, orientation, online/offline, and
351
+ * prefers-color-scheme / prefers-reduced-motion changes. Optionally polls the
352
+ * Battery Status API when available.
353
+ *
354
+ * @param options - `includeBattery` (default true), `batteryPollIntervalMs` (default 60000)
355
+ * @returns Current {@link DeviceData} snapshot
356
+ *
357
+ * @example
358
+ * ```tsx
359
+ * function DevicePanel() {
360
+ * const device = useDeviceData();
361
+ * return (
362
+ * <dl>
363
+ * <dt>Browser</dt><dd>{device.browser.name} {device.browser.version}</dd>
364
+ * <dt>OS</dt><dd>{device.os.name} {device.os.version}</dd>
365
+ * <dt>Language</dt><dd>{device.language}</dd>
366
+ * <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
367
+ * </dl>
368
+ * );
369
+ * }
370
+ * ```
371
+ */
372
+ export function useDeviceData(options: UseDeviceDataOptions = {}): DeviceData {
373
+ const {
374
+ includeBattery = true,
375
+ batteryPollIntervalMs = 60_000,
376
+ includeHighEntropy = true,
377
+ } = options;
378
+
379
+ const [data, setData] = useState<DeviceData>(() => getDeviceData());
380
+
381
+ const refresh = useCallback(() => {
382
+ setData((prev) => {
383
+ const next = getDeviceData();
384
+ return {
385
+ ...next,
386
+ ...(prev.battery ? { battery: prev.battery } : {}),
387
+ };
388
+ });
389
+ }, []);
390
+
391
+ useEffect(() => {
392
+ if (typeof window === "undefined") return;
393
+
394
+ const onResize = () => refresh();
395
+ window.addEventListener("resize", onResize);
396
+ window.addEventListener("orientationchange", onResize);
397
+ window.addEventListener("online", refresh);
398
+ window.addEventListener("offline", refresh);
399
+
400
+ const reducedMotionMq = window.matchMedia?.(
401
+ "(prefers-reduced-motion: reduce)"
402
+ );
403
+ const darkMq = window.matchMedia?.("(prefers-color-scheme: dark)");
404
+ const lightMq = window.matchMedia?.("(prefers-color-scheme: light)");
405
+
406
+ const onMediaChange = () => refresh();
407
+ reducedMotionMq?.addEventListener?.("change", onMediaChange);
408
+ darkMq?.addEventListener?.("change", onMediaChange);
409
+ lightMq?.addEventListener?.("change", onMediaChange);
410
+
411
+ return () => {
412
+ window.removeEventListener("resize", onResize);
413
+ window.removeEventListener("orientationchange", onResize);
414
+ window.removeEventListener("online", refresh);
415
+ window.removeEventListener("offline", refresh);
416
+ reducedMotionMq?.removeEventListener?.("change", onMediaChange);
417
+ darkMq?.removeEventListener?.("change", onMediaChange);
418
+ lightMq?.removeEventListener?.("change", onMediaChange);
419
+ };
420
+ }, [refresh]);
421
+
422
+ useEffect(() => {
423
+ if (!includeHighEntropy || typeof navigator === "undefined") return;
424
+
425
+ const uaData = (navigator as NavigatorWithExtras).userAgentData;
426
+ if (!uaData?.getHighEntropyValues) return;
427
+
428
+ let cancelled = false;
429
+
430
+ const updateHighEntropy = async () => {
431
+ const base = getDeviceData();
432
+ const enriched = await readHighEntropyBrowserOs(uaData, {
433
+ browser: base.browser,
434
+ os: base.os,
435
+ });
436
+ if (cancelled || !enriched) return;
437
+ setData((prev) => ({
438
+ ...prev,
439
+ browser: enriched.browser,
440
+ os: enriched.os,
441
+ }));
442
+ };
443
+
444
+ void updateHighEntropy();
445
+
446
+ return () => {
447
+ cancelled = true;
448
+ };
449
+ }, [includeHighEntropy]);
450
+
451
+ useEffect(() => {
452
+ if (!includeBattery || typeof navigator === "undefined") return;
453
+
454
+ let cancelled = false;
455
+ let intervalId: ReturnType<typeof setInterval> | undefined;
456
+
457
+ const updateBattery = async () => {
458
+ const battery = await readBattery();
459
+ if (cancelled || battery === undefined) return;
460
+ setData((prev) => ({ ...prev, battery }));
461
+ };
462
+
463
+ void updateBattery();
464
+ if (batteryPollIntervalMs > 0) {
465
+ intervalId = setInterval(
466
+ () => void updateBattery(),
467
+ batteryPollIntervalMs
468
+ );
469
+ }
470
+
471
+ return () => {
472
+ cancelled = true;
473
+ if (intervalId !== undefined) clearInterval(intervalId);
474
+ };
475
+ }, [includeBattery, batteryPollIntervalMs]);
476
+
477
+ return data;
478
+ }