preact-missing-hooks 4.7.0 → 4.8.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,285 @@
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
+ /** Snapshot of device / browser data from native Navigator and related APIs */
33
+ export interface DeviceData {
34
+ userAgent: string;
35
+ language: string;
36
+ languages: readonly string[];
37
+ platform: string;
38
+ cookieEnabled: boolean;
39
+ online: boolean;
40
+ hardwareConcurrency?: number;
41
+ /** Approximate device RAM in GB (Chrome / some browsers only) */
42
+ deviceMemory?: number;
43
+ maxTouchPoints: number;
44
+ vendor: string;
45
+ touch: boolean;
46
+ screen: DeviceScreenInfo;
47
+ viewport: DeviceViewportInfo;
48
+ userAgentData?: UserAgentDataInfo;
49
+ reducedMotion: boolean;
50
+ colorScheme: "light" | "dark" | "no-preference";
51
+ battery?: DeviceBatteryInfo;
52
+ }
53
+
54
+ export interface UseDeviceDataOptions {
55
+ /** Fetch battery info when the Battery Status API exists (default: true) */
56
+ includeBattery?: boolean;
57
+ /** Battery refresh interval in ms (default: 60000) */
58
+ batteryPollIntervalMs?: number;
59
+ }
60
+
61
+ interface NavigatorUAData {
62
+ mobile?: boolean;
63
+ platform?: string;
64
+ brands?: Array<{ brand: string; version: string }>;
65
+ }
66
+
67
+ type NavigatorWithExtras = Navigator & {
68
+ deviceMemory?: number;
69
+ userAgentData?: NavigatorUAData;
70
+ };
71
+
72
+ const SSR_DEVICE_DATA: DeviceData = {
73
+ userAgent: "",
74
+ language: "en",
75
+ languages: ["en"],
76
+ platform: "",
77
+ cookieEnabled: false,
78
+ online: true,
79
+ maxTouchPoints: 0,
80
+ vendor: "",
81
+ touch: false,
82
+ screen: {
83
+ width: 0,
84
+ height: 0,
85
+ availWidth: 0,
86
+ availHeight: 0,
87
+ colorDepth: 24,
88
+ pixelRatio: 1,
89
+ },
90
+ viewport: { width: 0, height: 0 },
91
+ reducedMotion: false,
92
+ colorScheme: "no-preference",
93
+ };
94
+
95
+ function getColorScheme(): DeviceData["colorScheme"] {
96
+ if (typeof window === "undefined" || !window.matchMedia) {
97
+ return "no-preference";
98
+ }
99
+ if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
100
+ return "dark";
101
+ }
102
+ if (window.matchMedia("(prefers-color-scheme: light)").matches) {
103
+ return "light";
104
+ }
105
+ return "no-preference";
106
+ }
107
+
108
+ function getReducedMotion(): boolean {
109
+ if (typeof window === "undefined" || !window.matchMedia) {
110
+ return false;
111
+ }
112
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
113
+ }
114
+
115
+ /** Reads synchronous device / browser data from Navigator, Screen, and matchMedia. */
116
+ export function getDeviceData(): DeviceData {
117
+ if (typeof navigator === "undefined") {
118
+ return SSR_DEVICE_DATA;
119
+ }
120
+
121
+ const nav = navigator as NavigatorWithExtras;
122
+ const screen =
123
+ typeof globalThis.screen !== "undefined" ? globalThis.screen : null;
124
+ const win =
125
+ typeof globalThis.window !== "undefined" ? globalThis.window : null;
126
+
127
+ const data: DeviceData = {
128
+ userAgent: nav.userAgent ?? "",
129
+ language: nav.language ?? "",
130
+ languages: nav.languages ? [...nav.languages] : [],
131
+ platform: nav.platform ?? "",
132
+ cookieEnabled: Boolean(nav.cookieEnabled),
133
+ online: Boolean(nav.onLine),
134
+ maxTouchPoints: nav.maxTouchPoints ?? 0,
135
+ vendor: nav.vendor ?? "",
136
+ touch: (nav.maxTouchPoints ?? 0) > 0,
137
+ screen: {
138
+ width: screen?.width ?? 0,
139
+ height: screen?.height ?? 0,
140
+ availWidth: screen?.availWidth ?? 0,
141
+ availHeight: screen?.availHeight ?? 0,
142
+ colorDepth: screen?.colorDepth ?? 24,
143
+ pixelRatio: win?.devicePixelRatio ?? 1,
144
+ },
145
+ viewport: {
146
+ width: win?.innerWidth ?? 0,
147
+ height: win?.innerHeight ?? 0,
148
+ },
149
+ reducedMotion: getReducedMotion(),
150
+ colorScheme: getColorScheme(),
151
+ };
152
+
153
+ if (typeof nav.hardwareConcurrency === "number") {
154
+ data.hardwareConcurrency = nav.hardwareConcurrency;
155
+ }
156
+ if (typeof nav.deviceMemory === "number") {
157
+ data.deviceMemory = nav.deviceMemory;
158
+ }
159
+
160
+ const uaData = nav.userAgentData;
161
+ if (uaData) {
162
+ data.userAgentData = {
163
+ mobile: Boolean(uaData.mobile),
164
+ platform: uaData.platform ?? "",
165
+ brands: uaData.brands ? [...uaData.brands] : [],
166
+ };
167
+ }
168
+
169
+ return data;
170
+ }
171
+
172
+ async function readBattery(): Promise<DeviceBatteryInfo | undefined> {
173
+ if (typeof navigator === "undefined") return undefined;
174
+ const getBattery = (
175
+ navigator as Navigator & {
176
+ getBattery?: () => Promise<{
177
+ charging: boolean;
178
+ level: number;
179
+ }>;
180
+ }
181
+ ).getBattery;
182
+ if (!getBattery) return undefined;
183
+ try {
184
+ const battery = await getBattery.call(navigator);
185
+ return { charging: battery.charging, level: battery.level };
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Extracts device and browser data from native Navigator, Screen, window, and
193
+ * matchMedia APIs. Updates on resize, orientation, online/offline, and
194
+ * prefers-color-scheme / prefers-reduced-motion changes. Optionally polls the
195
+ * Battery Status API when available.
196
+ *
197
+ * @param options - `includeBattery` (default true), `batteryPollIntervalMs` (default 60000)
198
+ * @returns Current {@link DeviceData} snapshot
199
+ *
200
+ * @example
201
+ * ```tsx
202
+ * function DevicePanel() {
203
+ * const device = useDeviceData();
204
+ * return (
205
+ * <dl>
206
+ * <dt>Language</dt><dd>{device.language}</dd>
207
+ * <dt>CPUs</dt><dd>{device.hardwareConcurrency ?? '—'}</dd>
208
+ * <dt>Viewport</dt><dd>{device.viewport.width}×{device.viewport.height}</dd>
209
+ * <dt>Theme</dt><dd>{device.colorScheme}</dd>
210
+ * </dl>
211
+ * );
212
+ * }
213
+ * ```
214
+ */
215
+ export function useDeviceData(options: UseDeviceDataOptions = {}): DeviceData {
216
+ const { includeBattery = true, batteryPollIntervalMs = 60_000 } = options;
217
+
218
+ const [data, setData] = useState<DeviceData>(() => getDeviceData());
219
+
220
+ const refresh = useCallback(() => {
221
+ setData((prev) => {
222
+ const next = getDeviceData();
223
+ return prev.battery ? { ...next, battery: prev.battery } : next;
224
+ });
225
+ }, []);
226
+
227
+ useEffect(() => {
228
+ if (typeof window === "undefined") return;
229
+
230
+ const onResize = () => refresh();
231
+ window.addEventListener("resize", onResize);
232
+ window.addEventListener("orientationchange", onResize);
233
+ window.addEventListener("online", refresh);
234
+ window.addEventListener("offline", refresh);
235
+
236
+ const reducedMotionMq = window.matchMedia?.(
237
+ "(prefers-reduced-motion: reduce)"
238
+ );
239
+ const darkMq = window.matchMedia?.("(prefers-color-scheme: dark)");
240
+ const lightMq = window.matchMedia?.("(prefers-color-scheme: light)");
241
+
242
+ const onMediaChange = () => refresh();
243
+ reducedMotionMq?.addEventListener?.("change", onMediaChange);
244
+ darkMq?.addEventListener?.("change", onMediaChange);
245
+ lightMq?.addEventListener?.("change", onMediaChange);
246
+
247
+ return () => {
248
+ window.removeEventListener("resize", onResize);
249
+ window.removeEventListener("orientationchange", onResize);
250
+ window.removeEventListener("online", refresh);
251
+ window.removeEventListener("offline", refresh);
252
+ reducedMotionMq?.removeEventListener?.("change", onMediaChange);
253
+ darkMq?.removeEventListener?.("change", onMediaChange);
254
+ lightMq?.removeEventListener?.("change", onMediaChange);
255
+ };
256
+ }, [refresh]);
257
+
258
+ useEffect(() => {
259
+ if (!includeBattery || typeof navigator === "undefined") return;
260
+
261
+ let cancelled = false;
262
+ let intervalId: ReturnType<typeof setInterval> | undefined;
263
+
264
+ const updateBattery = async () => {
265
+ const battery = await readBattery();
266
+ if (cancelled || battery === undefined) return;
267
+ setData((prev) => ({ ...prev, battery }));
268
+ };
269
+
270
+ void updateBattery();
271
+ if (batteryPollIntervalMs > 0) {
272
+ intervalId = setInterval(
273
+ () => void updateBattery(),
274
+ batteryPollIntervalMs
275
+ );
276
+ }
277
+
278
+ return () => {
279
+ cancelled = true;
280
+ if (intervalId !== undefined) clearInterval(intervalId);
281
+ };
282
+ }, [includeBattery, batteryPollIntervalMs]);
283
+
284
+ return data;
285
+ }
@@ -0,0 +1,213 @@
1
+ /** @jsx h */
2
+ import { h } from 'preact'
3
+ import { render, waitFor } from '@testing-library/preact'
4
+ import { getDeviceData, useDeviceData } from '../src/useDeviceData'
5
+
6
+ describe('getDeviceData', () => {
7
+ const originalNavigator = global.navigator
8
+ const originalScreen = global.screen
9
+ const originalWindow = global.window
10
+
11
+ afterEach(() => {
12
+ Object.defineProperty(global, 'navigator', {
13
+ value: originalNavigator,
14
+ writable: true,
15
+ })
16
+ Object.defineProperty(global, 'screen', {
17
+ value: originalScreen,
18
+ writable: true,
19
+ })
20
+ global.window = originalWindow
21
+ })
22
+
23
+ it('returns SSR-safe defaults when navigator is undefined', () => {
24
+ vi.stubGlobal('navigator', undefined)
25
+ const data = getDeviceData()
26
+ vi.unstubAllGlobals()
27
+ expect(data.userAgent).toBe('')
28
+ expect(data.online).toBe(true)
29
+ expect(data.viewport.width).toBe(0)
30
+ })
31
+
32
+ it('reads navigator and screen fields', () => {
33
+ Object.defineProperty(global, 'navigator', {
34
+ value: {
35
+ userAgent: 'TestAgent/1.0',
36
+ language: 'en-US',
37
+ languages: ['en-US', 'en'],
38
+ platform: 'Win32',
39
+ cookieEnabled: true,
40
+ onLine: true,
41
+ hardwareConcurrency: 8,
42
+ deviceMemory: 8,
43
+ maxTouchPoints: 0,
44
+ vendor: 'TestVendor',
45
+ },
46
+ writable: true,
47
+ })
48
+ Object.defineProperty(global, 'screen', {
49
+ value: {
50
+ width: 1920,
51
+ height: 1080,
52
+ availWidth: 1920,
53
+ availHeight: 1040,
54
+ colorDepth: 24,
55
+ },
56
+ writable: true,
57
+ })
58
+
59
+ const data = getDeviceData()
60
+ expect(data.userAgent).toBe('TestAgent/1.0')
61
+ expect(data.language).toBe('en-US')
62
+ expect(data.languages).toEqual(['en-US', 'en'])
63
+ expect(data.platform).toBe('Win32')
64
+ expect(data.hardwareConcurrency).toBe(8)
65
+ expect(data.deviceMemory).toBe(8)
66
+ expect(data.screen.width).toBe(1920)
67
+ expect(data.screen.height).toBe(1080)
68
+ expect(data.touch).toBe(false)
69
+ })
70
+
71
+ it('includes userAgentData when navigator.userAgentData exists', () => {
72
+ Object.defineProperty(global, 'navigator', {
73
+ value: {
74
+ ...originalNavigator,
75
+ userAgentData: {
76
+ mobile: true,
77
+ platform: 'Android',
78
+ brands: [{ brand: 'Chromium', version: '120' }],
79
+ },
80
+ },
81
+ writable: true,
82
+ })
83
+
84
+ const data = getDeviceData()
85
+ expect(data.userAgentData?.mobile).toBe(true)
86
+ expect(data.userAgentData?.platform).toBe('Android')
87
+ expect(data.userAgentData?.brands[0]?.brand).toBe('Chromium')
88
+ })
89
+ })
90
+
91
+ describe('useDeviceData', () => {
92
+ const originalNavigator = global.navigator
93
+ const originalAddEventListener = window.addEventListener
94
+ const originalRemoveEventListener = window.removeEventListener
95
+
96
+ afterEach(() => {
97
+ Object.defineProperty(global, 'navigator', {
98
+ value: originalNavigator,
99
+ writable: true,
100
+ })
101
+ window.addEventListener = originalAddEventListener
102
+ window.removeEventListener = originalRemoveEventListener
103
+ })
104
+
105
+ it('returns device data from navigator', () => {
106
+ Object.defineProperty(global, 'navigator', {
107
+ value: {
108
+ ...originalNavigator,
109
+ userAgent: 'HookTest/2.0',
110
+ language: 'fr',
111
+ languages: ['fr'],
112
+ platform: 'MacIntel',
113
+ cookieEnabled: true,
114
+ onLine: true,
115
+ maxTouchPoints: 5,
116
+ vendor: 'Apple',
117
+ },
118
+ writable: true,
119
+ })
120
+
121
+ function TestComponent() {
122
+ const device = useDeviceData({ includeBattery: false })
123
+ return (
124
+ <div>
125
+ <span data-testid="ua">{device.userAgent}</span>
126
+ <span data-testid="lang">{device.language}</span>
127
+ <span data-testid="touch">{String(device.touch)}</span>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ const { getByTestId } = render(<TestComponent />)
133
+ expect(getByTestId('ua').textContent).toBe('HookTest/2.0')
134
+ expect(getByTestId('lang').textContent).toBe('fr')
135
+ expect(getByTestId('touch').textContent).toBe('true')
136
+ })
137
+
138
+ it('updates viewport on resize', async () => {
139
+ let resizeHandler: () => void = () => {}
140
+ window.addEventListener = vi.fn((event: string, handler: () => void) => {
141
+ if (event === 'resize') resizeHandler = handler
142
+ }) as typeof window.addEventListener
143
+ window.removeEventListener = vi.fn()
144
+
145
+ Object.defineProperty(window, 'innerWidth', {
146
+ configurable: true,
147
+ value: 800,
148
+ })
149
+ Object.defineProperty(window, 'innerHeight', {
150
+ configurable: true,
151
+ value: 600,
152
+ })
153
+
154
+ Object.defineProperty(global, 'navigator', {
155
+ value: { ...originalNavigator, onLine: true },
156
+ writable: true,
157
+ })
158
+
159
+ function TestComponent() {
160
+ const device = useDeviceData({ includeBattery: false })
161
+ return (
162
+ <span data-testid="vw">{device.viewport.width}</span>
163
+ )
164
+ }
165
+
166
+ const { getByTestId } = render(<TestComponent />)
167
+ expect(getByTestId('vw').textContent).toBe('800')
168
+
169
+ Object.defineProperty(window, 'innerWidth', {
170
+ configurable: true,
171
+ value: 1024,
172
+ })
173
+ resizeHandler()
174
+
175
+ await waitFor(() => {
176
+ expect(getByTestId('vw').textContent).toBe('1024')
177
+ })
178
+ })
179
+
180
+ it('merges battery data when getBattery resolves', async () => {
181
+ Object.defineProperty(global, 'navigator', {
182
+ value: {
183
+ ...originalNavigator,
184
+ onLine: true,
185
+ getBattery: () =>
186
+ Promise.resolve({ charging: true, level: 0.75 }),
187
+ },
188
+ writable: true,
189
+ })
190
+
191
+ function TestComponent() {
192
+ const device = useDeviceData({
193
+ includeBattery: true,
194
+ batteryPollIntervalMs: 0,
195
+ })
196
+ return (
197
+ <span data-testid="battery">
198
+ {device.battery
199
+ ? `${device.battery.charging}-${device.battery.level}`
200
+ : 'none'}
201
+ </span>
202
+ )
203
+ }
204
+
205
+ const { getByTestId } = render(<TestComponent />)
206
+ await waitFor(
207
+ () => {
208
+ expect(getByTestId('battery').textContent).toBe('true-0.75')
209
+ },
210
+ { timeout: 3000 },
211
+ )
212
+ })
213
+ })