@uniflowed/hooks 0.0.0-alpha.10
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.
- package/async.js +170 -0
- package/browser.js +675 -0
- package/channels.js +224 -0
- package/dom.js +407 -0
- package/index.js +206 -0
- package/keyboard.js +328 -0
- package/lifecycle.js +114 -0
- package/package.json +33 -0
- package/state.js +520 -0
- package/timing.js +408 -0
package/browser.js
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/browser`: reading the environment, safely on a server.
|
|
4
|
+
//
|
|
5
|
+
// uf prerenders every static route, so each of these runs once where there is
|
|
6
|
+
// no `window`. `useSyncExternalStore` is what makes that correct rather than
|
|
7
|
+
// guarded: it takes a server snapshot as a separate argument, so the value
|
|
8
|
+
// used during prerender is stated rather than being whatever a `typeof window`
|
|
9
|
+
// check happened to fall through to. It also means React reads the value at
|
|
10
|
+
// the moment it commits, which is what stops a media query changing between
|
|
11
|
+
// render and paint from tearing.
|
|
12
|
+
//
|
|
13
|
+
// # What belongs in this module
|
|
14
|
+
//
|
|
15
|
+
// A reading of the one browser the page is in: its size, its scroll offset,
|
|
16
|
+
// its connection, its visibility, its position on the earth, the preferences
|
|
17
|
+
// the reader set, the permissions the reader granted. There is exactly one
|
|
18
|
+
// answer at a time, nobody has to pass anything in to ask, and a server has no
|
|
19
|
+
// answer at all — which is why every hook here either takes a server value
|
|
20
|
+
// from the caller or states an honest default.
|
|
21
|
+
//
|
|
22
|
+
// Not here: anything about a specific element, which needs a ref and lives in
|
|
23
|
+
// `dom.js`. `useDocumentVisible` is the closest call in the package and stays
|
|
24
|
+
// here, because the document is the environment rather than a node a caller
|
|
25
|
+
// chose.
|
|
26
|
+
//
|
|
27
|
+
// # The one that writes
|
|
28
|
+
//
|
|
29
|
+
// `useScrollLock` is the exception to "reading", and it is here rather than in
|
|
30
|
+
// `dom.js` because what it freezes is the page: there is one of it, the caller
|
|
31
|
+
// has no ref to it, and the compensation it has to make — the width of the
|
|
32
|
+
// scrollbar that is about to disappear — is a fact about the window rather
|
|
33
|
+
// than about any element. A lock that took a ref would be a different and
|
|
34
|
+
// rarer hook.
|
|
35
|
+
//
|
|
36
|
+
// # Two kinds of hook, and why the second kind exists
|
|
37
|
+
//
|
|
38
|
+
// Most of these are a `useSyncExternalStore` over an event the browser already
|
|
39
|
+
// fires, which is the shape that survives a prerender and a concurrent render
|
|
40
|
+
// without tearing. Three are not: `useGeolocation` and `usePermission` are
|
|
41
|
+
// subscriptions whose *first* value only arrives asynchronously, so there is
|
|
42
|
+
// nothing for a snapshot to return until it does, and `useScrollLock` writes.
|
|
43
|
+
// Each says so where it is defined.
|
|
44
|
+
|
|
45
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "@uniflowed/react";
|
|
46
|
+
|
|
47
|
+
import { useIsomorphicLayoutEffect } from "./lifecycle.js";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The part of a `navigator` this package reads.
|
|
51
|
+
*
|
|
52
|
+
* Not a declaration of `Navigator` — Flow ships one of those. This is the
|
|
53
|
+
* list of what these hooks actually touch, which is short enough to be worth
|
|
54
|
+
* writing down and is the reason none of them needs an `any`: everything below
|
|
55
|
+
* `browserWindow()` is checked against this.
|
|
56
|
+
*
|
|
57
|
+
* Every field is optional because a hosted document is not required to carry
|
|
58
|
+
* the whole of a browser. `navigator.geolocation` is `null` under happy-dom
|
|
59
|
+
* and absent under a bare Node global, and `navigator.connection` exists
|
|
60
|
+
* nowhere but Chromium.
|
|
61
|
+
*/
|
|
62
|
+
export type BrowserNavigator = {
|
|
63
|
+
readonly onLine?: boolean,
|
|
64
|
+
readonly userAgent?: string,
|
|
65
|
+
readonly clipboard?: ?{
|
|
66
|
+
readonly readText: () => Promise<string>,
|
|
67
|
+
readonly writeText: (text: string) => Promise<void>,
|
|
68
|
+
...
|
|
69
|
+
},
|
|
70
|
+
readonly geolocation?: ?Geolocation,
|
|
71
|
+
readonly permissions?: ?{
|
|
72
|
+
readonly query: (descriptor: { readonly name: string, ... }) => Promise<PermissionStatus>,
|
|
73
|
+
...
|
|
74
|
+
},
|
|
75
|
+
readonly connection?: ?NetworkConnection,
|
|
76
|
+
...
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** The Network Information object, which only Chromium has. */
|
|
80
|
+
export type NetworkConnection = {
|
|
81
|
+
readonly downlink?: number,
|
|
82
|
+
readonly effectiveType?: string,
|
|
83
|
+
readonly saveData?: boolean,
|
|
84
|
+
readonly addEventListener?: (type: string, listener: () => mixed) => void,
|
|
85
|
+
readonly removeEventListener?: (type: string, listener: () => mixed) => void,
|
|
86
|
+
...
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The part of a `window` this package reads.
|
|
91
|
+
*
|
|
92
|
+
* Same idea as [`BrowserNavigator`], and the same reason: naming what is
|
|
93
|
+
* touched turns every read in the package into a checked one. The observer
|
|
94
|
+
* constructors are optional because a browser old enough to lack one is a
|
|
95
|
+
* browser a hook here has to keep working in — it degrades to reporting
|
|
96
|
+
* nothing rather than throwing during a render.
|
|
97
|
+
*/
|
|
98
|
+
export type BrowserWindow = {
|
|
99
|
+
readonly document: Document,
|
|
100
|
+
readonly navigator: BrowserNavigator,
|
|
101
|
+
readonly localStorage?: ?Storage,
|
|
102
|
+
readonly sessionStorage?: ?Storage,
|
|
103
|
+
readonly innerWidth: number,
|
|
104
|
+
readonly innerHeight: number,
|
|
105
|
+
readonly scrollX: number,
|
|
106
|
+
readonly scrollY: number,
|
|
107
|
+
readonly matchMedia?: (query: string) => MediaQueryList,
|
|
108
|
+
readonly getComputedStyle?: (element: Element) => CSSStyleDeclaration,
|
|
109
|
+
readonly requestAnimationFrame?: (callback: (time: number) => mixed) => AnimationFrameID,
|
|
110
|
+
readonly cancelAnimationFrame?: (handle: AnimationFrameID) => void,
|
|
111
|
+
// Two overloads, the way Flow's own `EventTarget` is declared: a `storage`
|
|
112
|
+
// listener is handed a `StorageEvent` and needs its `key`, and narrowing an
|
|
113
|
+
// `Event` down to one at runtime would mean an `instanceof StorageEvent`
|
|
114
|
+
// against a name that is not defined in every host a uf test runs in.
|
|
115
|
+
readonly addEventListener: ((
|
|
116
|
+
type: "storage",
|
|
117
|
+
listener: (event: StorageEvent) => mixed,
|
|
118
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
119
|
+
) => void) &
|
|
120
|
+
((
|
|
121
|
+
type: string,
|
|
122
|
+
listener: (event: Event) => mixed,
|
|
123
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
124
|
+
) => void),
|
|
125
|
+
readonly removeEventListener: ((
|
|
126
|
+
type: "storage",
|
|
127
|
+
listener: (event: StorageEvent) => mixed,
|
|
128
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
129
|
+
) => void) &
|
|
130
|
+
((
|
|
131
|
+
type: string,
|
|
132
|
+
listener: (event: Event) => mixed,
|
|
133
|
+
options?: EventListenerOptionsOrUseCapture,
|
|
134
|
+
) => void),
|
|
135
|
+
readonly ResizeObserver?: Class<ResizeObserver>,
|
|
136
|
+
readonly IntersectionObserver?: Class<IntersectionObserver>,
|
|
137
|
+
readonly MutationObserver?: Class<MutationObserver>,
|
|
138
|
+
...
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The window these hooks listen to, or `null` where there is no browser.
|
|
143
|
+
*
|
|
144
|
+
* In a browser `globalThis` *is* the window, so `globalThis.addEventListener`
|
|
145
|
+
* looks correct. It is not correct anywhere a document has been installed onto
|
|
146
|
+
* another host's global — which is every uf test process, where `globalThis` is
|
|
147
|
+
* Node's and has no `addEventListener` at all. Ask the document's own window
|
|
148
|
+
* for its methods and both cases work.
|
|
149
|
+
*
|
|
150
|
+
* Exported because it is the first question every hook in this package asks,
|
|
151
|
+
* and an application writing its own prerender-safe hook has to ask it too.
|
|
152
|
+
* The single `?? globalThis` is this package's only unchecked step: `window` is
|
|
153
|
+
* `any` in Flow's own library definition, and this is where that stops.
|
|
154
|
+
*/
|
|
155
|
+
export function browserWindow(): BrowserWindow | null {
|
|
156
|
+
if (typeof globalThis.document === "undefined") {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return globalThis.window ?? globalThis;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A subscription to nothing, for a value that cannot change. */
|
|
163
|
+
function subscribeToNothing(): () => void {
|
|
164
|
+
return () => {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The server's answer to every "is this available" question. */
|
|
168
|
+
function unsupported(): boolean {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Whether a capability the browser may not have is there.
|
|
174
|
+
*
|
|
175
|
+
* The naive version — `typeof window.BroadcastChannel === "function"` in the
|
|
176
|
+
* render — is a hydration mismatch waiting to happen: the server says one
|
|
177
|
+
* thing, the client's first render says another, and React reports it against
|
|
178
|
+
* whatever markup happened to differ. Asked through `useSyncExternalStore`, the
|
|
179
|
+
* server's answer is `false`, the hydrating render agrees with it, and React
|
|
180
|
+
* re-renders with the truth immediately afterwards.
|
|
181
|
+
*
|
|
182
|
+
* `probe` is called during render, so it must only look — never install, never
|
|
183
|
+
* request. It is passed straight through rather than stabilised: a snapshot is
|
|
184
|
+
* a question the render is asking now, and a stable callback's body is
|
|
185
|
+
* installed in an insertion effect that has not run yet, so it would answer
|
|
186
|
+
* from the render before.
|
|
187
|
+
*/
|
|
188
|
+
export hook useSupported(probe: () => boolean): boolean {
|
|
189
|
+
return useSyncExternalStore(subscribeToNothing, probe, unsupported);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether a media query matches.
|
|
194
|
+
*
|
|
195
|
+
* `serverValue` is what a prerender should assume, and it has no honest
|
|
196
|
+
* default — a page that hides a sidebar under 48rem wants `false` on the
|
|
197
|
+
* server, and one that renders a mobile menu wants `true`. So the caller says.
|
|
198
|
+
*/
|
|
199
|
+
export hook useMediaQuery(query: string, serverValue: boolean = false): boolean {
|
|
200
|
+
const subscribe = useCallback(
|
|
201
|
+
(notify: () => void) => {
|
|
202
|
+
const list = browserWindow()?.matchMedia?.(query);
|
|
203
|
+
if (list == null) {
|
|
204
|
+
return () => {};
|
|
205
|
+
}
|
|
206
|
+
list.addEventListener("change", notify);
|
|
207
|
+
return () => list.removeEventListener("change", notify);
|
|
208
|
+
},
|
|
209
|
+
[query],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const snapshot = useCallback(
|
|
213
|
+
() => browserWindow()?.matchMedia?.(query)?.matches ?? serverValue,
|
|
214
|
+
[query, serverValue],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The reader's colour-scheme preference. */
|
|
221
|
+
export hook usePreferredColorScheme(serverValue: "light" | "dark" = "light"): "light" | "dark" {
|
|
222
|
+
return useMediaQuery("(prefers-color-scheme: dark)", serverValue === "dark") ? "dark" : "light";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether the reader has asked for less motion. */
|
|
226
|
+
export hook usePrefersReducedMotion(serverValue: boolean = false): boolean {
|
|
227
|
+
return useMediaQuery("(prefers-reduced-motion: reduce)", serverValue);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Whether the browser thinks it is online. */
|
|
231
|
+
export hook useOnline(serverValue: boolean = true): boolean {
|
|
232
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
233
|
+
const win = browserWindow();
|
|
234
|
+
if (win == null) {
|
|
235
|
+
return () => {};
|
|
236
|
+
}
|
|
237
|
+
win.addEventListener("online", notify);
|
|
238
|
+
win.addEventListener("offline", notify);
|
|
239
|
+
return () => {
|
|
240
|
+
win.removeEventListener("online", notify);
|
|
241
|
+
win.removeEventListener("offline", notify);
|
|
242
|
+
};
|
|
243
|
+
}, []);
|
|
244
|
+
|
|
245
|
+
const snapshot = useCallback(
|
|
246
|
+
() => browserWindow()?.navigator.onLine ?? serverValue,
|
|
247
|
+
[serverValue],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Whether the document is the one the reader is looking at. */
|
|
254
|
+
export hook useDocumentVisible(serverValue: boolean = true): boolean {
|
|
255
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
256
|
+
const document = browserWindow()?.document;
|
|
257
|
+
if (document == null) {
|
|
258
|
+
return () => {};
|
|
259
|
+
}
|
|
260
|
+
document.addEventListener("visibilitychange", notify);
|
|
261
|
+
return () => document.removeEventListener("visibilitychange", notify);
|
|
262
|
+
}, []);
|
|
263
|
+
|
|
264
|
+
const snapshot = useCallback(() => {
|
|
265
|
+
const document = browserWindow()?.document;
|
|
266
|
+
return document == null ? serverValue : document.visibilityState !== "hidden";
|
|
267
|
+
}, [serverValue]);
|
|
268
|
+
|
|
269
|
+
return useSyncExternalStore(subscribe, snapshot, () => serverValue);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* How far something has been scrolled.
|
|
274
|
+
*
|
|
275
|
+
* Defined here rather than in `dom.js` because `dom.js` imports this module
|
|
276
|
+
* and not the other way round; `useScroll` over an element uses the same
|
|
277
|
+
* shape, and one name for one thing is worth the arrow.
|
|
278
|
+
*/
|
|
279
|
+
export type ScrollOffset = {| readonly x: number, readonly y: number |};
|
|
280
|
+
|
|
281
|
+
/** How big something is. Shared with `useElementSize` for the same reason. */
|
|
282
|
+
export type Size = {| readonly width: number, readonly height: number |};
|
|
283
|
+
|
|
284
|
+
/** Read `"12x34"` back into a pair. */
|
|
285
|
+
function unpack(packed: string): ScrollOffset {
|
|
286
|
+
const [x, y] = packed.split("x");
|
|
287
|
+
return { x: Number(x), y: Number(y) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The size of the viewport. */
|
|
291
|
+
export hook useWindowSize(serverValue?: Size): Size {
|
|
292
|
+
const width = serverValue?.width ?? 0;
|
|
293
|
+
const height = serverValue?.height ?? 0;
|
|
294
|
+
|
|
295
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
296
|
+
const win = browserWindow();
|
|
297
|
+
if (win == null) {
|
|
298
|
+
return () => {};
|
|
299
|
+
}
|
|
300
|
+
win.addEventListener("resize", notify);
|
|
301
|
+
return () => win.removeEventListener("resize", notify);
|
|
302
|
+
}, []);
|
|
303
|
+
|
|
304
|
+
// A string snapshot, because `useSyncExternalStore` compares snapshots by
|
|
305
|
+
// identity: returning a fresh object every time would re-render on every
|
|
306
|
+
// check, which is an infinite loop React reports rather than tolerates.
|
|
307
|
+
const packed = useSyncExternalStore(
|
|
308
|
+
subscribe,
|
|
309
|
+
useCallback(() => {
|
|
310
|
+
const win = browserWindow();
|
|
311
|
+
return win == null ? `${width}x${height}` : `${win.innerWidth}x${win.innerHeight}`;
|
|
312
|
+
}, [width, height]),
|
|
313
|
+
useCallback(() => `${width}x${height}`, [width, height]),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const size = unpack(packed);
|
|
317
|
+
return { width: size.x, height: size.y };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* How far the page has been scrolled.
|
|
322
|
+
*
|
|
323
|
+
* The same packed-string snapshot as `useWindowSize`, for the same reason, and
|
|
324
|
+
* a passive listener because a scroll handler that could call
|
|
325
|
+
* `preventDefault` blocks scrolling on a touch screen until it has run.
|
|
326
|
+
*/
|
|
327
|
+
export hook useWindowScroll(serverValue?: ScrollOffset): ScrollOffset {
|
|
328
|
+
const x = serverValue?.x ?? 0;
|
|
329
|
+
const y = serverValue?.y ?? 0;
|
|
330
|
+
|
|
331
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
332
|
+
const win = browserWindow();
|
|
333
|
+
if (win == null) {
|
|
334
|
+
return () => {};
|
|
335
|
+
}
|
|
336
|
+
win.addEventListener("scroll", notify, { passive: true });
|
|
337
|
+
return () => win.removeEventListener("scroll", notify);
|
|
338
|
+
}, []);
|
|
339
|
+
|
|
340
|
+
const packed = useSyncExternalStore(
|
|
341
|
+
subscribe,
|
|
342
|
+
useCallback(() => {
|
|
343
|
+
const win = browserWindow();
|
|
344
|
+
return win == null ? `${x}x${y}` : `${win.scrollX}x${win.scrollY}`;
|
|
345
|
+
}, [x, y]),
|
|
346
|
+
useCallback(() => `${x}x${y}`, [x, y]),
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
return unpack(packed);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* How many components are currently holding the page still.
|
|
354
|
+
*
|
|
355
|
+
* Module-level rather than per-component, because two dialogs open at once
|
|
356
|
+
* must not have the first one to close put the page back: the page unlocks
|
|
357
|
+
* when the last of them lets go. Strict Mode's mount-unmount-mount is
|
|
358
|
+
* balanced by construction — the effect increments and its cleanup decrements.
|
|
359
|
+
*/
|
|
360
|
+
let scrollLocks = 0;
|
|
361
|
+
|
|
362
|
+
/** What to put back when the last lock is released. */
|
|
363
|
+
let releaseScroll: (() => void) | null = null;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The widest a scrollbar is allowed to be believed.
|
|
367
|
+
*
|
|
368
|
+
* The compensation is `innerWidth - documentElement.clientWidth`, which is the
|
|
369
|
+
* scrollbar's width in a browser that lays out and is the whole viewport in
|
|
370
|
+
* one that does not — a headless document reports a client width of zero.
|
|
371
|
+
* Padding the page by a viewport pushes it off screen, so a number that could
|
|
372
|
+
* not be a scrollbar is treated as no measurement at all.
|
|
373
|
+
*/
|
|
374
|
+
const WIDEST_SCROLLBAR = 40;
|
|
375
|
+
|
|
376
|
+
function lockScroll(): void {
|
|
377
|
+
scrollLocks += 1;
|
|
378
|
+
if (scrollLocks > 1) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const win = browserWindow();
|
|
382
|
+
const body = win?.document.body;
|
|
383
|
+
if (win == null || body == null) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const previousOverflow = body.style.overflow;
|
|
387
|
+
const previousPadding = body.style.paddingRight;
|
|
388
|
+
const root = win.document.documentElement;
|
|
389
|
+
const gap = root == null ? 0 : win.innerWidth - root.clientWidth;
|
|
390
|
+
body.style.overflow = "hidden";
|
|
391
|
+
if (gap > 0 && gap <= WIDEST_SCROLLBAR) {
|
|
392
|
+
const computed = win.getComputedStyle?.(body).paddingRight ?? "";
|
|
393
|
+
body.style.paddingRight = `${(Number.parseFloat(computed) || 0) + gap}px`;
|
|
394
|
+
}
|
|
395
|
+
releaseScroll = () => {
|
|
396
|
+
body.style.overflow = previousOverflow;
|
|
397
|
+
body.style.paddingRight = previousPadding;
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function unlockScroll(): void {
|
|
402
|
+
scrollLocks = Math.max(0, scrollLocks - 1);
|
|
403
|
+
if (scrollLocks === 0 && releaseScroll != null) {
|
|
404
|
+
releaseScroll();
|
|
405
|
+
releaseScroll = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Hold the page still while `locked`.
|
|
411
|
+
*
|
|
412
|
+
* A layout effect, so the page is frozen before the frame in which the dialog
|
|
413
|
+
* that asked for it appears — an ordinary effect lets one frame of scrolling
|
|
414
|
+
* through, which reads as a jump.
|
|
415
|
+
*
|
|
416
|
+
* On a server this does nothing at all: effects do not run during a prerender,
|
|
417
|
+
* so a locked dialog rendered into HTML leaves the markup alone.
|
|
418
|
+
*/
|
|
419
|
+
export hook useScrollLock(locked: boolean): void {
|
|
420
|
+
useIsomorphicLayoutEffect(() => {
|
|
421
|
+
if (!locked) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
lockScroll();
|
|
425
|
+
return unlockScroll;
|
|
426
|
+
}, [locked]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** How good the connection is, as the browser grades it. */
|
|
430
|
+
export type EffectiveConnectionType = "slow-2g" | "2g" | "3g" | "4g";
|
|
431
|
+
|
|
432
|
+
/** What the browser will say about the connection. */
|
|
433
|
+
export type Network = {|
|
|
434
|
+
readonly online: boolean,
|
|
435
|
+
/** Estimated bandwidth in megabits per second, where the browser reports it. */
|
|
436
|
+
readonly downlink: number | null,
|
|
437
|
+
readonly effectiveType: EffectiveConnectionType | null,
|
|
438
|
+
/** Whether the reader has asked for less data to be used. */
|
|
439
|
+
readonly saveData: boolean,
|
|
440
|
+
/** Whether anything beyond `online` was actually measured. */
|
|
441
|
+
readonly supported: boolean,
|
|
442
|
+
|};
|
|
443
|
+
|
|
444
|
+
const EFFECTIVE_TYPES: $ReadOnlyArray<EffectiveConnectionType> = ["slow-2g", "2g", "3g", "4g"];
|
|
445
|
+
|
|
446
|
+
function asEffectiveType(value: string): EffectiveConnectionType | null {
|
|
447
|
+
for (const known of EFFECTIVE_TYPES) {
|
|
448
|
+
if (known === value) {
|
|
449
|
+
return known;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* What the browser will say about the connection.
|
|
457
|
+
*
|
|
458
|
+
* Only Chromium implements Network Information, so `supported` is part of the
|
|
459
|
+
* answer rather than something a caller has to find out by seeing nulls. The
|
|
460
|
+
* `online` half is everywhere, and stays true even where the rest is unknown —
|
|
461
|
+
* which is the field almost every caller wants.
|
|
462
|
+
*
|
|
463
|
+
* The snapshot is a packed string for the reason `useWindowSize` gives: an
|
|
464
|
+
* object rebuilt on every check never compares equal, and `useSyncExternalStore`
|
|
465
|
+
* would re-render forever.
|
|
466
|
+
*/
|
|
467
|
+
export hook useNetwork(serverValue: boolean = true): Network {
|
|
468
|
+
const subscribe = useCallback((notify: () => void) => {
|
|
469
|
+
const win = browserWindow();
|
|
470
|
+
if (win == null) {
|
|
471
|
+
return () => {};
|
|
472
|
+
}
|
|
473
|
+
const connection = win.navigator.connection;
|
|
474
|
+
win.addEventListener("online", notify);
|
|
475
|
+
win.addEventListener("offline", notify);
|
|
476
|
+
connection?.addEventListener?.("change", notify);
|
|
477
|
+
return () => {
|
|
478
|
+
win.removeEventListener("online", notify);
|
|
479
|
+
win.removeEventListener("offline", notify);
|
|
480
|
+
connection?.removeEventListener?.("change", notify);
|
|
481
|
+
};
|
|
482
|
+
}, []);
|
|
483
|
+
|
|
484
|
+
const server = useCallback(() => `${serverValue ? "1" : "0"}|||0`, [serverValue]);
|
|
485
|
+
|
|
486
|
+
const packed = useSyncExternalStore(
|
|
487
|
+
subscribe,
|
|
488
|
+
useCallback(() => {
|
|
489
|
+
const navigator = browserWindow()?.navigator;
|
|
490
|
+
if (navigator == null) {
|
|
491
|
+
return `${serverValue ? "1" : "0"}|||0`;
|
|
492
|
+
}
|
|
493
|
+
const connection = navigator.connection;
|
|
494
|
+
const online = navigator.onLine ?? serverValue;
|
|
495
|
+
if (connection == null) {
|
|
496
|
+
return `${online ? "1" : "0"}|||0`;
|
|
497
|
+
}
|
|
498
|
+
const downlink = connection.downlink;
|
|
499
|
+
return [
|
|
500
|
+
online ? "1" : "0",
|
|
501
|
+
downlink == null ? "" : String(downlink),
|
|
502
|
+
connection.effectiveType ?? "",
|
|
503
|
+
connection.saveData === true ? "1" : "0",
|
|
504
|
+
].join("|");
|
|
505
|
+
}, [serverValue]),
|
|
506
|
+
server,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return useMemo(() => {
|
|
510
|
+
const [online, downlink, effectiveType, saveData] = packed.split("|");
|
|
511
|
+
return {
|
|
512
|
+
online: online === "1",
|
|
513
|
+
downlink: downlink === "" ? null : Number(downlink),
|
|
514
|
+
effectiveType: asEffectiveType(effectiveType),
|
|
515
|
+
saveData: saveData === "1",
|
|
516
|
+
supported: downlink !== "" || effectiveType !== "",
|
|
517
|
+
};
|
|
518
|
+
}, [packed]);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Where the reader is, to the accuracy the browser was willing to give. */
|
|
522
|
+
export type Geoposition = {|
|
|
523
|
+
readonly latitude: number,
|
|
524
|
+
readonly longitude: number,
|
|
525
|
+
/** Radius of a 95% confidence circle, in metres. */
|
|
526
|
+
readonly accuracy: number,
|
|
527
|
+
readonly timestamp: number,
|
|
528
|
+
|};
|
|
529
|
+
|
|
530
|
+
/** What `useGeolocation` knows so far. */
|
|
531
|
+
export type GeolocationReading = {|
|
|
532
|
+
readonly position: Geoposition | null,
|
|
533
|
+
readonly error: Error | null,
|
|
534
|
+
readonly supported: boolean,
|
|
535
|
+
|};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Watch where the reader is.
|
|
539
|
+
*
|
|
540
|
+
* An effect rather than a `useSyncExternalStore`, and the difference is not
|
|
541
|
+
* stylistic: there is no snapshot to read. The browser has no "current
|
|
542
|
+
* position" property to ask — the first value arrives in a callback, after a
|
|
543
|
+
* permission prompt the reader may take a minute to answer or never answer at
|
|
544
|
+
* all. So `position` is `null` until one arrives, on a server and in a browser
|
|
545
|
+
* alike, which is also what makes it hydration-safe.
|
|
546
|
+
*
|
|
547
|
+
* Mounting this asks the reader for permission. Mount it on the page that
|
|
548
|
+
* needs a position, not at the top of an application.
|
|
549
|
+
*/
|
|
550
|
+
export hook useGeolocation(options?: {|
|
|
551
|
+
readonly enabled?: boolean,
|
|
552
|
+
readonly highAccuracy?: boolean,
|
|
553
|
+
readonly maximumAge?: number,
|
|
554
|
+
readonly timeout?: number,
|
|
555
|
+
|}): GeolocationReading {
|
|
556
|
+
const enabled = options?.enabled ?? true;
|
|
557
|
+
const highAccuracy = options?.highAccuracy ?? false;
|
|
558
|
+
const maximumAge = options?.maximumAge;
|
|
559
|
+
const timeout = options?.timeout;
|
|
560
|
+
|
|
561
|
+
const supported = useSupported(() => browserWindow()?.navigator.geolocation != null);
|
|
562
|
+
const [reading, setReading] = useState<{|
|
|
563
|
+
position: Geoposition | null,
|
|
564
|
+
error: Error | null,
|
|
565
|
+
|}>({ position: null, error: null });
|
|
566
|
+
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
const geolocation = browserWindow()?.navigator.geolocation;
|
|
569
|
+
if (!enabled || geolocation == null) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const watch = geolocation.watchPosition(
|
|
573
|
+
(position: Position) => {
|
|
574
|
+
setReading({
|
|
575
|
+
position: {
|
|
576
|
+
latitude: position.coords.latitude,
|
|
577
|
+
longitude: position.coords.longitude,
|
|
578
|
+
accuracy: position.coords.accuracy,
|
|
579
|
+
timestamp: position.timestamp,
|
|
580
|
+
},
|
|
581
|
+
error: null,
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
(failure: PositionError) => {
|
|
585
|
+
// The position already on screen is kept: a timeout on the third
|
|
586
|
+
// reading does not mean the second one stopped being true.
|
|
587
|
+
setReading((current) => ({ ...current, error: new Error(failure.message) }));
|
|
588
|
+
},
|
|
589
|
+
{ enableHighAccuracy: highAccuracy, maximumAge, timeout },
|
|
590
|
+
);
|
|
591
|
+
return () => geolocation.clearWatch(watch);
|
|
592
|
+
}, [enabled, highAccuracy, maximumAge, timeout]);
|
|
593
|
+
|
|
594
|
+
return useMemo(
|
|
595
|
+
() => ({ position: reading.position, error: reading.error, supported }),
|
|
596
|
+
[reading, supported],
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** A permission this hook knows how to ask about. */
|
|
601
|
+
export type PermissionName =
|
|
602
|
+
| "geolocation"
|
|
603
|
+
| "notifications"
|
|
604
|
+
| "camera"
|
|
605
|
+
| "microphone"
|
|
606
|
+
| "clipboard-read"
|
|
607
|
+
| "clipboard-write"
|
|
608
|
+
| "persistent-storage"
|
|
609
|
+
| "push"
|
|
610
|
+
| "midi";
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* What the browser says about a permission.
|
|
614
|
+
*
|
|
615
|
+
* `"unknown"` is one value for four situations that a caller treats the same
|
|
616
|
+
* way — no Permissions API, a name this browser does not recognise, an answer
|
|
617
|
+
* that has not arrived yet, and a server render. Splitting them would make
|
|
618
|
+
* every caller write the same four-armed `match` to reach the same conclusion.
|
|
619
|
+
*/
|
|
620
|
+
export type PermissionAnswer = "granted" | "denied" | "prompt" | "unknown";
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Whether the reader has granted a permission, without asking for it.
|
|
624
|
+
*
|
|
625
|
+
* Querying is not prompting: this reports the current state and follows it if
|
|
626
|
+
* the reader changes their mind in browser settings. Asking for the permission
|
|
627
|
+
* is the API's own job — `getUserMedia`, `watchPosition` — and doing it from
|
|
628
|
+
* here would make a hook that reads have a side effect nobody asked for.
|
|
629
|
+
*
|
|
630
|
+
* An effect rather than a store, for the reason `useGeolocation` gives: the
|
|
631
|
+
* answer is a promise, so there is nothing to read synchronously.
|
|
632
|
+
*/
|
|
633
|
+
export hook usePermission(name: PermissionName): PermissionAnswer {
|
|
634
|
+
const [answer, setAnswer] = useState<PermissionAnswer>("unknown");
|
|
635
|
+
|
|
636
|
+
useEffect(() => {
|
|
637
|
+
const permissions = browserWindow()?.navigator.permissions;
|
|
638
|
+
if (permissions == null) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Set when the effect is superseded, so an answer that arrives for a name
|
|
642
|
+
// the caller has stopped asking about is dropped rather than shown.
|
|
643
|
+
let ignore = false;
|
|
644
|
+
let status: PermissionStatus | null = null;
|
|
645
|
+
const onChange = () => {
|
|
646
|
+
if (!ignore && status != null) {
|
|
647
|
+
setAnswer(status.state);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
permissions.query({ name }).then(
|
|
652
|
+
(result: PermissionStatus) => {
|
|
653
|
+
if (ignore) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
status = result;
|
|
657
|
+
setAnswer(result.state);
|
|
658
|
+
result.addEventListener("change", onChange);
|
|
659
|
+
},
|
|
660
|
+
() => {
|
|
661
|
+
// A name this browser does not know rejects rather than answering.
|
|
662
|
+
if (!ignore) {
|
|
663
|
+
setAnswer("unknown");
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
return () => {
|
|
669
|
+
ignore = true;
|
|
670
|
+
status?.removeEventListener("change", onChange);
|
|
671
|
+
};
|
|
672
|
+
}, [name]);
|
|
673
|
+
|
|
674
|
+
return answer;
|
|
675
|
+
}
|