@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/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
+ }