@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/channels.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/channels`: a value that came from outside this page.
|
|
4
|
+
//
|
|
5
|
+
// Two hooks, and the thing they have in common is the reason they are not in
|
|
6
|
+
// `state.js`. A piece of state is a value the component owns and can always
|
|
7
|
+
// read. What is here is a value that *arrives*: a message another tab sent, a
|
|
8
|
+
// string the reader copied out of a different application. Between arrivals
|
|
9
|
+
// there is nothing to read, the browser may refuse the whole facility, and
|
|
10
|
+
// every operation is asynchronous or fire-and-forget.
|
|
11
|
+
//
|
|
12
|
+
// `useStorage` is the one to compare them with, and the line is durability
|
|
13
|
+
// rather than direction. `localStorage` holds a value: it is there on the next
|
|
14
|
+
// visit, two components reading the same key must agree about it, and
|
|
15
|
+
// `useSyncExternalStore` is the right shape because there is always a snapshot.
|
|
16
|
+
// A broadcast happens once and is gone; a clipboard belongs to the operating
|
|
17
|
+
// system and can change without telling the page. Neither has a snapshot, so
|
|
18
|
+
// neither is a store.
|
|
19
|
+
//
|
|
20
|
+
// # What belongs in this module
|
|
21
|
+
//
|
|
22
|
+
// A hook whose subject is the page's boundary — something crossing into or out
|
|
23
|
+
// of this document that is not the server. Not the server: a request is
|
|
24
|
+
// `@uniflowed/query`'s and `@uniflowed/fetch`'s, and the distinction is worth
|
|
25
|
+
// keeping because those come with caching, retry and revalidation that none of
|
|
26
|
+
// this wants.
|
|
27
|
+
//
|
|
28
|
+
// # Before hydration
|
|
29
|
+
//
|
|
30
|
+
// Both hooks report `supported: false` on a server *and* on the client's first
|
|
31
|
+
// render, through `useSupported` — see the reason there. Neither opens
|
|
32
|
+
// anything during a render: the channel is created in an effect, and the
|
|
33
|
+
// clipboard is only ever touched from a callback the reader caused.
|
|
34
|
+
|
|
35
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
36
|
+
|
|
37
|
+
import { browserWindow, useSupported } from "./browser.js";
|
|
38
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The part of `BroadcastChannel` this module uses.
|
|
42
|
+
*
|
|
43
|
+
* Declared here rather than taken from Flow's library definition for two
|
|
44
|
+
* reasons: the constructor is looked up at runtime and may be absent, so it
|
|
45
|
+
* has to be a value with a known constructor signature; and the listener is
|
|
46
|
+
* typed with the one field that is read, which is what removes the
|
|
47
|
+
* `instanceof MessageEvent` a narrowing would otherwise need — a name that is
|
|
48
|
+
* not defined in every host these tests run in.
|
|
49
|
+
*/
|
|
50
|
+
declare class Channel {
|
|
51
|
+
constructor(name: string): void;
|
|
52
|
+
postMessage(message: mixed): void;
|
|
53
|
+
close(): void;
|
|
54
|
+
addEventListener(type: "message", listener: (event: { data: mixed, ... }) => mixed): void;
|
|
55
|
+
removeEventListener(type: "message", listener: (event: { data: mixed, ... }) => mixed): void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `BroadcastChannel`, which is the host's rather than the document's.
|
|
60
|
+
*
|
|
61
|
+
* In a browser the two are the same object. In a process where a document has
|
|
62
|
+
* been installed onto another runtime's global they are not, and the host is
|
|
63
|
+
* the one that has this: a broadcast channel is a facility of the runtime, and
|
|
64
|
+
* a hosted `Window` is not required to carry one. That is also why this is not
|
|
65
|
+
* a field on `BrowserWindow` — it is not read from the window.
|
|
66
|
+
*/
|
|
67
|
+
declare var BroadcastChannel: Class<Channel> | void;
|
|
68
|
+
|
|
69
|
+
function channelConstructor(): Class<Channel> | null {
|
|
70
|
+
return typeof BroadcastChannel === "undefined" ? null : (BroadcastChannel ?? null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What `useBroadcast` hands back. */
|
|
74
|
+
export type UseBroadcastReturn<T> = {|
|
|
75
|
+
/** Send to every other page on this origin. Never to this one. */
|
|
76
|
+
readonly post: (message: T) => void,
|
|
77
|
+
readonly supported: boolean,
|
|
78
|
+
|};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Send and receive messages between the tabs of one origin.
|
|
82
|
+
*
|
|
83
|
+
* A callback rather than a `latest` value, and that is the design decision in
|
|
84
|
+
* this file. A message is an event: it happened, it was worth acting on, and
|
|
85
|
+
* holding the most recent one as state invites a component to treat it as the
|
|
86
|
+
* truth — which it is not, because the tab that just opened has never received
|
|
87
|
+
* one and has no way to ask. Where two tabs need to agree about a *value*,
|
|
88
|
+
* `useStorage` is the hook: it has a snapshot every tab can read, and it
|
|
89
|
+
* already notifies across tabs.
|
|
90
|
+
*
|
|
91
|
+
* A channel does not deliver to the page that posted. Two components in one
|
|
92
|
+
* tab sharing a channel therefore do not hear each other, which is the
|
|
93
|
+
* specification's behaviour and not this hook's.
|
|
94
|
+
*
|
|
95
|
+
* `T` is what the caller promises to send. Nothing checks what arrives — it
|
|
96
|
+
* was serialised by another copy of the page, possibly an older deployment of
|
|
97
|
+
* it — so `onMessage` is handed `mixed` and validating it is the caller's, with
|
|
98
|
+
* `@uniflowed/validator` if it matters.
|
|
99
|
+
*/
|
|
100
|
+
export hook useBroadcast<T>(
|
|
101
|
+
name: string,
|
|
102
|
+
onMessage: (message: mixed) => mixed,
|
|
103
|
+
): UseBroadcastReturn<T> {
|
|
104
|
+
const stable = useStableCallback(onMessage);
|
|
105
|
+
const supported = useSupported(() => channelConstructor() != null);
|
|
106
|
+
|
|
107
|
+
// Written in an effect and read only from `post`, which a render never
|
|
108
|
+
// calls. A render that React throws away cannot see it, and a render that
|
|
109
|
+
// commits does not need to.
|
|
110
|
+
const channel = useRef<Channel | null>(null);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
const Constructor = channelConstructor();
|
|
114
|
+
if (Constructor == null) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const open = new Constructor(name);
|
|
118
|
+
channel.current = open;
|
|
119
|
+
const listener = (event: { data: mixed, ... }) => {
|
|
120
|
+
stable(event.data);
|
|
121
|
+
};
|
|
122
|
+
open.addEventListener("message", listener);
|
|
123
|
+
return () => {
|
|
124
|
+
open.removeEventListener("message", listener);
|
|
125
|
+
open.close();
|
|
126
|
+
// Safe unconditionally: React runs an effect's cleanup before the effect
|
|
127
|
+
// that replaces it, so this can never clear a channel a later mount has
|
|
128
|
+
// already installed — including Strict Mode's second mount.
|
|
129
|
+
channel.current = null;
|
|
130
|
+
};
|
|
131
|
+
}, [name, stable]);
|
|
132
|
+
|
|
133
|
+
const post = useStableCallback((message: T) => {
|
|
134
|
+
channel.current?.postMessage(message);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return useMemo(() => ({ post, supported }), [post, supported]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What `useClipboard` hands back. */
|
|
141
|
+
export type UseClipboardReturn = {|
|
|
142
|
+
/** Put text on the clipboard. Resolves to whether it worked. */
|
|
143
|
+
readonly copy: (text: string) => Promise<boolean>,
|
|
144
|
+
/** Read the clipboard, which may prompt. `null` when it is refused. */
|
|
145
|
+
readonly read: () => Promise<string | null>,
|
|
146
|
+
/** True for `resetAfter` milliseconds following a successful copy. */
|
|
147
|
+
readonly copied: boolean,
|
|
148
|
+
readonly supported: boolean,
|
|
149
|
+
|};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The system clipboard.
|
|
153
|
+
*
|
|
154
|
+
* `copied` exists because every copy button needs it: a tick that appears for
|
|
155
|
+
* a moment is the only feedback a copy has. It resets itself, and the timer is
|
|
156
|
+
* cleared at unmount, so a component that goes away during the pause does not
|
|
157
|
+
* set state afterwards.
|
|
158
|
+
*
|
|
159
|
+
* Reading is a function rather than a value, deliberately. There is no event
|
|
160
|
+
* for "the clipboard changed", so a hook that exposed its contents as state
|
|
161
|
+
* would have to poll — and polling the clipboard means asking for permission
|
|
162
|
+
* repeatedly and reading whatever the reader copied out of their password
|
|
163
|
+
* manager. `read()` is called when the reader asks for a paste, which is when
|
|
164
|
+
* the browser expects to be asked.
|
|
165
|
+
*
|
|
166
|
+
* `document.execCommand("copy")` is not used as a fallback. It is deprecated,
|
|
167
|
+
* it requires a hidden element and a selection, and the browsers that lack the
|
|
168
|
+
* asynchronous clipboard in 2026 are the ones that would not run this package
|
|
169
|
+
* anyway. `supported` says so instead of pretending.
|
|
170
|
+
*/
|
|
171
|
+
export hook useClipboard(options?: {| readonly resetAfter?: number |}): UseClipboardReturn {
|
|
172
|
+
const resetAfter = options?.resetAfter ?? 1_500;
|
|
173
|
+
const supported = useSupported(() => browserWindow()?.navigator.clipboard != null);
|
|
174
|
+
const [copied, setCopied] = useState(false);
|
|
175
|
+
|
|
176
|
+
const timer = useRef<TimeoutID | null>(null);
|
|
177
|
+
useEffect(
|
|
178
|
+
() => () => {
|
|
179
|
+
if (timer.current != null) {
|
|
180
|
+
clearTimeout(timer.current);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
[],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const copy = useCallback(
|
|
187
|
+
async (text: string): Promise<boolean> => {
|
|
188
|
+
const clipboard = browserWindow()?.navigator.clipboard;
|
|
189
|
+
if (clipboard == null) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
await clipboard.writeText(text);
|
|
194
|
+
} catch {
|
|
195
|
+
// Refused, or the document was not focused when the write landed.
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
setCopied(true);
|
|
199
|
+
if (timer.current != null) {
|
|
200
|
+
clearTimeout(timer.current);
|
|
201
|
+
}
|
|
202
|
+
timer.current = setTimeout(() => {
|
|
203
|
+
timer.current = null;
|
|
204
|
+
setCopied(false);
|
|
205
|
+
}, resetAfter);
|
|
206
|
+
return true;
|
|
207
|
+
},
|
|
208
|
+
[resetAfter],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const read = useCallback(async (): Promise<string | null> => {
|
|
212
|
+
const clipboard = browserWindow()?.navigator.clipboard;
|
|
213
|
+
if (clipboard == null) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return await clipboard.readText();
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
return useMemo(() => ({ copy, read, copied, supported }), [copy, read, copied, supported]);
|
|
224
|
+
}
|
package/dom.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/dom`: watching one node.
|
|
4
|
+
//
|
|
5
|
+
// Each of these takes a ref rather than returning one, so a component can put
|
|
6
|
+
// several on the same element and can hand the ref to something else as well.
|
|
7
|
+
// The listener is attached in a layout effect, so it is in place before the
|
|
8
|
+
// browser paints — a click that lands in the same frame as the mount is a real
|
|
9
|
+
// case on a touch screen.
|
|
10
|
+
//
|
|
11
|
+
// # What belongs in this module
|
|
12
|
+
//
|
|
13
|
+
// A hook whose subject is a particular element the caller is holding: listen
|
|
14
|
+
// to it, measure it, notice a pointer over it, notice it entering the
|
|
15
|
+
// viewport, notice its children change, notice a press that lasts. The
|
|
16
|
+
// signature is the giveaway — if it takes a ref, it is here.
|
|
17
|
+
//
|
|
18
|
+
// The neighbour it is most often confused with is `browser.js`, which reads
|
|
19
|
+
// the ambient environment: the window's size, its scroll offset, whether the
|
|
20
|
+
// document is visible, what the reader's media queries say. Those need no ref
|
|
21
|
+
// because there is only one of the thing they read, and they are built on
|
|
22
|
+
// `useSyncExternalStore` with a stated server value. These need a ref because
|
|
23
|
+
// there are as many answers as there are elements, and they are built on
|
|
24
|
+
// effects because there is nothing to read until one is mounted.
|
|
25
|
+
//
|
|
26
|
+
// The file was called `element.js`, which named the argument rather than the
|
|
27
|
+
// job and left `browser.js` looking like its opposite when it is its sibling.
|
|
28
|
+
//
|
|
29
|
+
// # Before hydration
|
|
30
|
+
//
|
|
31
|
+
// Nothing here runs. Every hook in this file does its work in an effect, and
|
|
32
|
+
// effects do not run during a prerender — so each one reports its stated
|
|
33
|
+
// starting value (`false` for a pointer or a focus, zero for a size or a
|
|
34
|
+
// scroll offset) in the server's HTML and in the client's first render, which
|
|
35
|
+
// is what makes them agree. There is no `typeof window` in this file: a ref is
|
|
36
|
+
// null on a server for the same reason it is null before mount, and one
|
|
37
|
+
// branch covers both.
|
|
38
|
+
|
|
39
|
+
import { useEffect, useMemo, useRef, useState } from "@uniflowed/react";
|
|
40
|
+
|
|
41
|
+
import type { ScrollOffset, Size } from "./browser.js";
|
|
42
|
+
import { browserWindow } from "./browser.js";
|
|
43
|
+
import { useIsomorphicLayoutEffect, useStableCallback } from "./lifecycle.js";
|
|
44
|
+
|
|
45
|
+
/** A ref object these hooks read: what `useRef` and `useElementRef` return. */
|
|
46
|
+
export type Ref<T> = { current: T | null };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a listener can be attached to.
|
|
50
|
+
*
|
|
51
|
+
* A ref, or a function that finds the target. The function form is what covers
|
|
52
|
+
* the window and the document, which no ref points at.
|
|
53
|
+
*/
|
|
54
|
+
export type ListenerTarget<T> = Ref<T> | (() => T | null) | null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The part of `addEventListener`'s options a hook here passes on.
|
|
58
|
+
*
|
|
59
|
+
* `signal` is deliberately absent: these hooks remove their own listener in the
|
|
60
|
+
* effect's cleanup, and a second, independent way to remove it would be a
|
|
61
|
+
* second thing that can be wrong.
|
|
62
|
+
*/
|
|
63
|
+
export type ListenerOptions = {|
|
|
64
|
+
readonly capture?: boolean,
|
|
65
|
+
readonly passive?: boolean,
|
|
66
|
+
readonly once?: boolean,
|
|
67
|
+
|};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Listen to an event on a target, cleaning up after itself.
|
|
71
|
+
*
|
|
72
|
+
* The handler is stabilised, so passing an inline arrow does not tear the
|
|
73
|
+
* listener down and set it up again on every render — which is the bug this
|
|
74
|
+
* hook exists to prevent and the reason it does not take a dependency array.
|
|
75
|
+
*
|
|
76
|
+
* The target is read once, when the listener is attached. Passing a ref and
|
|
77
|
+
* later pointing it at a different element does not move the listener; a
|
|
78
|
+
* component whose target changes should let the element unmount and mount
|
|
79
|
+
* again, which is what React does anyway when the element is conditional.
|
|
80
|
+
*/
|
|
81
|
+
export hook useEventListener<TTarget extends EventTarget>(
|
|
82
|
+
target: ListenerTarget<TTarget>,
|
|
83
|
+
name: string,
|
|
84
|
+
handler: (event: Event) => mixed,
|
|
85
|
+
options?: ListenerOptions,
|
|
86
|
+
): void {
|
|
87
|
+
const stable = useStableCallback(handler);
|
|
88
|
+
const find = useStableCallback(() =>
|
|
89
|
+
typeof target === "function" ? target() : (target?.current ?? null),
|
|
90
|
+
);
|
|
91
|
+
// A ref is a stable object and a function target is not, so only the ref
|
|
92
|
+
// form goes into the dependency array: an inline `() => window` would
|
|
93
|
+
// otherwise re-subscribe on every render, and that is the whole bug.
|
|
94
|
+
const ref = typeof target === "function" ? null : target;
|
|
95
|
+
const capture = options?.capture ?? false;
|
|
96
|
+
const passive = options?.passive;
|
|
97
|
+
const once = options?.once ?? false;
|
|
98
|
+
|
|
99
|
+
useIsomorphicLayoutEffect(() => {
|
|
100
|
+
const node = find();
|
|
101
|
+
if (node == null) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const listener = (event: Event) => {
|
|
105
|
+
stable(event);
|
|
106
|
+
};
|
|
107
|
+
node.addEventListener(name, listener, { capture, passive, once });
|
|
108
|
+
return () => node.removeEventListener(name, listener, { capture });
|
|
109
|
+
}, [find, ref, name, stable, capture, passive, once]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Call `handler` when a press lands outside `ref`.
|
|
114
|
+
*
|
|
115
|
+
* `pointerdown` rather than `click`, because a menu that closes on click stays
|
|
116
|
+
* open for the whole press — and because a click whose press started inside
|
|
117
|
+
* the menu and ended outside it should not close it.
|
|
118
|
+
*/
|
|
119
|
+
export hook useClickOutside(ref: Ref<HTMLElement>, handler: (event: Event) => mixed): void {
|
|
120
|
+
const stable = useStableCallback(handler);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const document = browserWindow()?.document;
|
|
124
|
+
if (document == null) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const listener = (event: Event) => {
|
|
128
|
+
const node = ref.current;
|
|
129
|
+
const target = event.target;
|
|
130
|
+
if (node != null && target instanceof Node && !node.contains(target)) {
|
|
131
|
+
stable(event);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
document.addEventListener("pointerdown", listener);
|
|
135
|
+
return () => document.removeEventListener("pointerdown", listener);
|
|
136
|
+
}, [ref, stable]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether the pointer is over the element. */
|
|
140
|
+
export hook useHover(ref: Ref<HTMLElement>): boolean {
|
|
141
|
+
const [hovered, setHovered] = useState(false);
|
|
142
|
+
useEventListener(ref, "pointerenter", () => setHovered(true));
|
|
143
|
+
useEventListener(ref, "pointerleave", () => setHovered(false));
|
|
144
|
+
return hovered;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether focus is inside the element. */
|
|
148
|
+
export hook useFocusWithin(ref: Ref<HTMLElement>): boolean {
|
|
149
|
+
const [within, setWithin] = useState(false);
|
|
150
|
+
useEventListener(ref, "focusin", () => setWithin(true));
|
|
151
|
+
useEventListener(ref, "focusout", () => setWithin(false));
|
|
152
|
+
return within;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Where a pointer event happened, or `null` for one that carries no position.
|
|
157
|
+
*
|
|
158
|
+
* `MouseEvent` rather than `PointerEvent`, because a pointer event is a mouse
|
|
159
|
+
* event by specification and the narrower name is not defined in every
|
|
160
|
+
* environment a uf test runs in — an `instanceof` against a name that does not
|
|
161
|
+
* exist is a `ReferenceError`, not a `false`.
|
|
162
|
+
*/
|
|
163
|
+
function pointOf(event: Event): {| x: number, y: number |} | null {
|
|
164
|
+
return event instanceof MouseEvent ? { x: event.clientX, y: event.clientY } : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Call `handler` when a press on the element lasts.
|
|
169
|
+
*
|
|
170
|
+
* Cancelled by letting go, by the pointer leaving, and by the pointer moving
|
|
171
|
+
* further than `moveThreshold` — a press that turns into a scroll or a drag is
|
|
172
|
+
* not a long press, and a version that only watched for `pointerup` fires a
|
|
173
|
+
* context menu in the middle of a fling.
|
|
174
|
+
*
|
|
175
|
+
* The handler is called once per press, while the finger is still down, which
|
|
176
|
+
* is when a long press is supposed to be felt.
|
|
177
|
+
*/
|
|
178
|
+
export hook useLongPress(
|
|
179
|
+
ref: Ref<HTMLElement>,
|
|
180
|
+
handler: (event: Event) => mixed,
|
|
181
|
+
options?: {| readonly delay?: number, readonly moveThreshold?: number |},
|
|
182
|
+
): void {
|
|
183
|
+
const stable = useStableCallback(handler);
|
|
184
|
+
const delay = options?.delay ?? 500;
|
|
185
|
+
const moveThreshold = options?.moveThreshold ?? 10;
|
|
186
|
+
|
|
187
|
+
// Written and read only from event handlers and the effect's cleanup, never
|
|
188
|
+
// during a render, so a render React throws away cannot see a press.
|
|
189
|
+
const pending = useRef<TimeoutID | null>(null);
|
|
190
|
+
const origin = useRef<{| x: number, y: number |} | null>(null);
|
|
191
|
+
|
|
192
|
+
const cancel = useStableCallback(() => {
|
|
193
|
+
if (pending.current != null) {
|
|
194
|
+
clearTimeout(pending.current);
|
|
195
|
+
pending.current = null;
|
|
196
|
+
}
|
|
197
|
+
origin.current = null;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
useEventListener(ref, "pointerdown", (event: Event) => {
|
|
201
|
+
cancel();
|
|
202
|
+
origin.current = pointOf(event);
|
|
203
|
+
pending.current = setTimeout(() => {
|
|
204
|
+
pending.current = null;
|
|
205
|
+
stable(event);
|
|
206
|
+
}, delay);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
useEventListener(ref, "pointermove", (event: Event) => {
|
|
210
|
+
const start = origin.current;
|
|
211
|
+
const moved = pointOf(event);
|
|
212
|
+
if (start == null || moved == null) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (Math.hypot(moved.x - start.x, moved.y - start.y) > moveThreshold) {
|
|
216
|
+
cancel();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
useEventListener(ref, "pointerup", () => cancel());
|
|
221
|
+
useEventListener(ref, "pointercancel", () => cancel());
|
|
222
|
+
useEventListener(ref, "pointerleave", () => cancel());
|
|
223
|
+
|
|
224
|
+
useEffect(() => cancel, [cancel]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The element's size, as the browser measures it.
|
|
229
|
+
*
|
|
230
|
+
* A `ResizeObserver` rather than a window resize listener, because an element
|
|
231
|
+
* changes size when its content changes, when a sibling grows, and when a
|
|
232
|
+
* container query fires — none of which resizes the window.
|
|
233
|
+
*/
|
|
234
|
+
export hook useElementSize(ref: Ref<HTMLElement>): Size {
|
|
235
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
236
|
+
|
|
237
|
+
useIsomorphicLayoutEffect(() => {
|
|
238
|
+
const node = ref.current;
|
|
239
|
+
const Observer = browserWindow()?.ResizeObserver;
|
|
240
|
+
if (node == null || Observer == null) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const observer = new Observer((entries: $ReadOnlyArray<ResizeObserverEntry>) => {
|
|
244
|
+
const entry = entries[0];
|
|
245
|
+
if (entry == null) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const box = entry.contentRect;
|
|
249
|
+
// Only on a real change: an observer that fires with the same numbers
|
|
250
|
+
// would re-render forever.
|
|
251
|
+
setSize((current) =>
|
|
252
|
+
current.width === box.width && current.height === box.height
|
|
253
|
+
? current
|
|
254
|
+
: { width: box.width, height: box.height },
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
observer.observe(node);
|
|
258
|
+
return () => observer.disconnect();
|
|
259
|
+
}, [ref]);
|
|
260
|
+
|
|
261
|
+
return size;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Whether the element is in the viewport. */
|
|
265
|
+
export hook useIntersecting(
|
|
266
|
+
ref: Ref<HTMLElement>,
|
|
267
|
+
options?: {| readonly rootMargin?: string, readonly threshold?: number |},
|
|
268
|
+
): boolean {
|
|
269
|
+
const [intersecting, setIntersecting] = useState(false);
|
|
270
|
+
const rootMargin = options?.rootMargin;
|
|
271
|
+
const threshold = options?.threshold;
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
const node = ref.current;
|
|
275
|
+
const Observer = browserWindow()?.IntersectionObserver;
|
|
276
|
+
if (node == null || Observer == null) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const observer = new Observer(
|
|
280
|
+
(entries: Array<IntersectionObserverEntry>) => {
|
|
281
|
+
const entry = entries[0];
|
|
282
|
+
if (entry != null) {
|
|
283
|
+
setIntersecting(entry.isIntersecting);
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
{ rootMargin, threshold },
|
|
287
|
+
);
|
|
288
|
+
observer.observe(node);
|
|
289
|
+
return () => observer.disconnect();
|
|
290
|
+
}, [ref, rootMargin, threshold]);
|
|
291
|
+
|
|
292
|
+
return intersecting;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** What part of the tree under the element to watch. */
|
|
296
|
+
export type MutationOptions = {|
|
|
297
|
+
/** Children added or removed. The default, unless another kind is asked for. */
|
|
298
|
+
readonly childList?: boolean,
|
|
299
|
+
/** Descendants as well as the element itself. */
|
|
300
|
+
readonly subtree?: boolean,
|
|
301
|
+
readonly attributes?: boolean,
|
|
302
|
+
readonly characterData?: boolean,
|
|
303
|
+
/** Only these attributes, where `attributes` is on. */
|
|
304
|
+
readonly attributeFilter?: $ReadOnlyArray<string>,
|
|
305
|
+
|};
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Call `handler` when the element's markup changes.
|
|
309
|
+
*
|
|
310
|
+
* The last resort of the three observers, and worth saying so: a size is a
|
|
311
|
+
* `ResizeObserver`, a position is an `IntersectionObserver`, and this is for
|
|
312
|
+
* the case where something outside React edits the DOM — a third-party widget,
|
|
313
|
+
* a browser extension, a `contenteditable`. Watching a tree React owns in
|
|
314
|
+
* order to learn about React's own updates is a mistake this hook cannot
|
|
315
|
+
* prevent but should not encourage.
|
|
316
|
+
*
|
|
317
|
+
* `attributeFilter` is compared by its contents rather than its identity, so
|
|
318
|
+
* an array written inline in the call does not re-observe on every render.
|
|
319
|
+
*/
|
|
320
|
+
export hook useMutationObserver(
|
|
321
|
+
ref: Ref<HTMLElement>,
|
|
322
|
+
handler: (records: $ReadOnlyArray<MutationRecord>) => mixed,
|
|
323
|
+
options?: MutationOptions,
|
|
324
|
+
): void {
|
|
325
|
+
const stable = useStableCallback(handler);
|
|
326
|
+
const attributes = options?.attributes ?? false;
|
|
327
|
+
const characterData = options?.characterData ?? false;
|
|
328
|
+
const childList = options?.childList ?? !(attributes || characterData);
|
|
329
|
+
const subtree = options?.subtree ?? false;
|
|
330
|
+
const attributeFilter = options?.attributeFilter;
|
|
331
|
+
const filterKey = attributeFilter == null ? null : attributeFilter.join(",");
|
|
332
|
+
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
const node = ref.current;
|
|
335
|
+
const Observer = browserWindow()?.MutationObserver;
|
|
336
|
+
if (node == null || Observer == null) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// Flow's `MutationObserverInit` requires one of the three kinds to be
|
|
340
|
+
// literally `true`, which is the specification's own rule: an observer
|
|
341
|
+
// that watches nothing throws. The branches are that rule, not a style.
|
|
342
|
+
const filter = filterKey == null ? undefined : filterKey.split(",");
|
|
343
|
+
const init: MutationObserverInit = childList
|
|
344
|
+
? { childList: true, subtree, attributes, characterData, attributeFilter: filter }
|
|
345
|
+
: attributes
|
|
346
|
+
? { attributes: true, subtree, characterData, attributeFilter: filter }
|
|
347
|
+
: { characterData: true, subtree };
|
|
348
|
+
const observer = new Observer((records: Array<MutationRecord>) => {
|
|
349
|
+
stable(records);
|
|
350
|
+
});
|
|
351
|
+
observer.observe(node, init);
|
|
352
|
+
return () => observer.disconnect();
|
|
353
|
+
}, [ref, stable, childList, subtree, attributes, characterData, filterKey]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* How far the element has been scrolled.
|
|
358
|
+
*
|
|
359
|
+
* The element's own offset, not the page's — `useWindowScroll` is the page's,
|
|
360
|
+
* and lives in `browser.js` because there is only one page.
|
|
361
|
+
*
|
|
362
|
+
* A passive listener, because a scroll handler that could call
|
|
363
|
+
* `preventDefault` blocks scrolling on a touch screen until it has run; and a
|
|
364
|
+
* layout effect for the first reading, because a container restored to a saved
|
|
365
|
+
* offset should not report zero for one frame.
|
|
366
|
+
*/
|
|
367
|
+
export hook useScroll(ref: Ref<HTMLElement>): ScrollOffset {
|
|
368
|
+
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
369
|
+
|
|
370
|
+
const read = useStableCallback(() => {
|
|
371
|
+
const node = ref.current;
|
|
372
|
+
if (node == null) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const x = node.scrollLeft;
|
|
376
|
+
const y = node.scrollTop;
|
|
377
|
+
setOffset((current) => (current.x === x && current.y === y ? current : { x, y }));
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
useIsomorphicLayoutEffect(read, [read]);
|
|
381
|
+
useEventListener(ref, "scroll", () => read(), { passive: true });
|
|
382
|
+
|
|
383
|
+
return offset;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** A ref for one of the hooks above, typed for the element you will attach it to. */
|
|
387
|
+
export hook useElementRef<T extends HTMLElement>(): Ref<T> {
|
|
388
|
+
return useRef<T | null>(null);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The element a ref points at, as a value a render can depend on.
|
|
393
|
+
*
|
|
394
|
+
* A ref is not state: React does not re-render when `current` changes, and
|
|
395
|
+
* reading `ref.current` during a render is a rule violation because the render
|
|
396
|
+
* that reads it may be one React throws away. A component that has to *render*
|
|
397
|
+
* something derived from its own element — a measurement, a portal target —
|
|
398
|
+
* needs the element as state, which is what a callback ref gives.
|
|
399
|
+
*
|
|
400
|
+
* The returned function is stable, so passing it as `ref={setNode}` does not
|
|
401
|
+
* detach and reattach on every render.
|
|
402
|
+
*/
|
|
403
|
+
export hook useElementState<T extends HTMLElement>(): [T | null, (node: T | null) => void] {
|
|
404
|
+
const [node, setNode] = useState<T | null>(null);
|
|
405
|
+
const attach = useStableCallback((next: T | null) => setNode(next));
|
|
406
|
+
return useMemo(() => [node, attach], [node, attach]);
|
|
407
|
+
}
|