@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/index.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks`: the hooks a React application writes anyway.
|
|
4
|
+
//
|
|
5
|
+
// Every hook here is one that people write by hand in every project and get
|
|
6
|
+
// subtly wrong in the same way each time — a timer that calls a stale closure,
|
|
7
|
+
// a subscription re-established on every keystroke, a slow request overwriting
|
|
8
|
+
// a fast one, persisted state that differs between the server render and the
|
|
9
|
+
// first paint, a shortcut that fires while somebody is typing, a dialog that
|
|
10
|
+
// unlocks the page while a second dialog is still open.
|
|
11
|
+
//
|
|
12
|
+
// VueUse is the benchmark, and most of what it offers is not here on purpose.
|
|
13
|
+
// Its `Reactivity`, `Watch` and `Array` categories exist because Vue's `ref`
|
|
14
|
+
// is a mutable box that has to be wrapped, unwrapped, synced and derived;
|
|
15
|
+
// React has no box, and `useMemo` over a plain array is the whole of
|
|
16
|
+
// `useArrayFilter`. Its `Component` category is Vue's template machinery —
|
|
17
|
+
// `templateRef`, `unrefElement`, `useVModel` — which is `ref` and props here.
|
|
18
|
+
// Porting either would have produced hooks whose only purpose was to look
|
|
19
|
+
// familiar. The "Readiness" section below says what is here, what is
|
|
20
|
+
// deliberately elsewhere in uf, and what is simply not built.
|
|
21
|
+
//
|
|
22
|
+
// # Prerendering is the constraint that shapes the surface
|
|
23
|
+
//
|
|
24
|
+
// uf prerenders every static route, so each of these runs once where there is
|
|
25
|
+
// no `window`. The browser hooks are built on `useSyncExternalStore`, which
|
|
26
|
+
// takes the server's value as a separate argument — so what a prerender sees is
|
|
27
|
+
// *stated* rather than being whatever a `typeof window` check fell through to,
|
|
28
|
+
// and React reads the value when it commits rather than when it renders, which
|
|
29
|
+
// is what stops a media query that changes mid-render from tearing.
|
|
30
|
+
//
|
|
31
|
+
// Where there is no honest default, the caller supplies one: a page that hides
|
|
32
|
+
// its sidebar under 48rem wants `false` on the server and one that renders a
|
|
33
|
+
// mobile menu wants `true`, and a library cannot know which.
|
|
34
|
+
//
|
|
35
|
+
// The hooks built on effects rather than stores — everything in `dom.js`, and
|
|
36
|
+
// the three in `browser.js` whose first value only arrives in a callback — do
|
|
37
|
+
// nothing at all before hydration, and report the same starting value on both
|
|
38
|
+
// sides for that reason. Each module's header says which it is and why.
|
|
39
|
+
//
|
|
40
|
+
// # React's rules are the design, not a constraint on it
|
|
41
|
+
//
|
|
42
|
+
// Nothing here writes during a render, reads a ref during a render, or depends
|
|
43
|
+
// on a render having happened exactly once. Callbacks that cross into effects
|
|
44
|
+
// go through `useStableCallback`, whose ref is written in an insertion effect
|
|
45
|
+
// rather than in the body, so Strict Mode's double render and a render the
|
|
46
|
+
// React Compiler skips are both correct. Every effect's cleanup removes
|
|
47
|
+
// exactly what its body added, which is what makes Strict Mode's
|
|
48
|
+
// mount-unmount-mount balanced — including the module-level counters behind
|
|
49
|
+
// `useScrollLock` and `useStorage`.
|
|
50
|
+
//
|
|
51
|
+
// # How the package is laid out
|
|
52
|
+
//
|
|
53
|
+
// Eight modules beside this one, split by what a hook's subject is — because
|
|
54
|
+
// that is the question a reader looking for one actually asks:
|
|
55
|
+
//
|
|
56
|
+
// - `lifecycle.js` — the component itself: mounted, previous, run once.
|
|
57
|
+
// - `state.js` — a value the component owns, with the operations that suit it:
|
|
58
|
+
// toggle, counter, list, set, cycle, undo/redo, storage.
|
|
59
|
+
// - `timing.js` — when something runs: intervals, timeouts, debounce,
|
|
60
|
+
// throttle, frames, idleness, and the clock behind "3 minutes ago".
|
|
61
|
+
// - `async.js` — one promise: its states, its abort signal, its retries.
|
|
62
|
+
// - `browser.js` — the ambient environment: viewport, scroll, connection,
|
|
63
|
+
// position, permissions, preferences.
|
|
64
|
+
// - `dom.js` — one element the caller holds a ref to: listen, measure,
|
|
65
|
+
// observe, press.
|
|
66
|
+
// - `keyboard.js` — what is being pressed: a chord, and a held key.
|
|
67
|
+
// - `channels.js` — a value that came from outside the page: another tab, the
|
|
68
|
+
// system clipboard.
|
|
69
|
+
//
|
|
70
|
+
// The two that are easiest to confuse are `browser.js` and `dom.js`, so each
|
|
71
|
+
// says so in its own header: `browser.js` needs no ref because there is one
|
|
72
|
+
// browser, and `dom.js` needs one because there are as many answers as there
|
|
73
|
+
// are elements. `keyboard.js` is neither, which is why it is a third file
|
|
74
|
+
// rather than a corner of one of them, and its header says what the four hard
|
|
75
|
+
// parts of a shortcut are.
|
|
76
|
+
//
|
|
77
|
+
// They sit here rather than under an `internal/`, and each has its own
|
|
78
|
+
// subpath. Every name in them is exported from this file, so calling them
|
|
79
|
+
// internal would have described nothing true, and it cost a reader a directory
|
|
80
|
+
// hop to reach the first line of code. `internal/` is for a module consumers
|
|
81
|
+
// must not reach; this package has none.
|
|
82
|
+
//
|
|
83
|
+
// `lifecycle.js` and `browser.js` are the two the others import —
|
|
84
|
+
// `useStableCallback` and `browserWindow` — and both are still subjects rather
|
|
85
|
+
// than bags of shared helpers. A hook goes in `lifecycle.js` because it is
|
|
86
|
+
// about the component's life, never because more than one file wanted it.
|
|
87
|
+
//
|
|
88
|
+
// # Readiness
|
|
89
|
+
//
|
|
90
|
+
// **Implemented and tested.** The component's life; the state shapes; every
|
|
91
|
+
// timer, including the adaptive schedule behind `useTimeAgo`; `useAsync` with
|
|
92
|
+
// abort and retry; media queries, colour scheme, reduced motion, online,
|
|
93
|
+
// document visibility, window size and scroll, scroll lock; element size,
|
|
94
|
+
// intersection, mutations, hover, focus-within, click-outside, long press,
|
|
95
|
+
// element scroll, the element as state; key chords and held keys; storage with
|
|
96
|
+
// cross-tab sync;
|
|
97
|
+
// broadcast channels; the clipboard. `tests/library/hooks.test.js` covers
|
|
98
|
+
// behaviour and cleanup, and `tests/library/hooks-ssr.test.js` renders the
|
|
99
|
+
// whole surface in a process that has no DOM at all.
|
|
100
|
+
//
|
|
101
|
+
// **Experimental.** `useGeolocation`, `useNetwork` and `usePermission`. The
|
|
102
|
+
// shapes are settled and the cleanup is right, but the browsers disagree about
|
|
103
|
+
// them more than the rest of this package does: Network Information is
|
|
104
|
+
// Chromium's alone, the Permissions API rejects rather than answers for names
|
|
105
|
+
// it does not know, and geolocation cannot be exercised end to end in a
|
|
106
|
+
// headless document — the tests cover the unsupported path, the subscription
|
|
107
|
+
// and its teardown, not a real fix. Treat the fields as advisory.
|
|
108
|
+
//
|
|
109
|
+
// **Not implemented, and not planned here.** Everything whose subject is
|
|
110
|
+
// somewhere else in uf: a request with a cache is `@uniflowed/query`, a form
|
|
111
|
+
// field is `@uniflowed/form`, an atom two routes read is `@uniflowed/state`, a
|
|
112
|
+
// rendered instant is `@uniflowed/web`'s `Time`, styling and dark mode are
|
|
113
|
+
// `@uniflowed/stylex`, and a virtual list or an infinite scroller is
|
|
114
|
+
// `@uniflowed/ui`. Beyond those: the device APIs VueUse wraps that a general
|
|
115
|
+
// application does not reach for — Bluetooth, gamepads, USB, speech, wake
|
|
116
|
+
// lock, screen capture, web workers, battery, vibration — are absent rather
|
|
117
|
+
// than shallow. A wrapper over one of those is three lines and a `supported`
|
|
118
|
+
// flag; what makes it worth shipping is knowing the failure modes, and this
|
|
119
|
+
// package does not yet.
|
|
120
|
+
|
|
121
|
+
export type { Async, AsyncOptions } from "./async.js";
|
|
122
|
+
export type {
|
|
123
|
+
BrowserNavigator,
|
|
124
|
+
BrowserWindow,
|
|
125
|
+
EffectiveConnectionType,
|
|
126
|
+
GeolocationReading,
|
|
127
|
+
Geoposition,
|
|
128
|
+
Network,
|
|
129
|
+
NetworkConnection,
|
|
130
|
+
PermissionAnswer,
|
|
131
|
+
PermissionName,
|
|
132
|
+
ScrollOffset,
|
|
133
|
+
Size,
|
|
134
|
+
} from "./browser.js";
|
|
135
|
+
export type { UseBroadcastReturn, UseClipboardReturn } from "./channels.js";
|
|
136
|
+
export type { ListenerOptions, ListenerTarget, MutationOptions, Ref } from "./dom.js";
|
|
137
|
+
export type { KeyComboOptions } from "./keyboard.js";
|
|
138
|
+
export type {
|
|
139
|
+
UseCounterReturn,
|
|
140
|
+
UseCycleReturn,
|
|
141
|
+
UseListReturn,
|
|
142
|
+
UseSetReturn,
|
|
143
|
+
UseToggleReturn,
|
|
144
|
+
UseUndoableReturn,
|
|
145
|
+
} from "./state.js";
|
|
146
|
+
|
|
147
|
+
export { useAsync } from "./async.js";
|
|
148
|
+
export {
|
|
149
|
+
useIsomorphicLayoutEffect,
|
|
150
|
+
useMount,
|
|
151
|
+
useMounted,
|
|
152
|
+
usePrevious,
|
|
153
|
+
useRerender,
|
|
154
|
+
useStableCallback,
|
|
155
|
+
useUnmount,
|
|
156
|
+
} from "./lifecycle.js";
|
|
157
|
+
export {
|
|
158
|
+
useAnimationFrame,
|
|
159
|
+
useDebouncedCallback,
|
|
160
|
+
useDebouncedValue,
|
|
161
|
+
useIdle,
|
|
162
|
+
useInterval,
|
|
163
|
+
useNow,
|
|
164
|
+
useThrottledCallback,
|
|
165
|
+
useTimeAgo,
|
|
166
|
+
useTimeout,
|
|
167
|
+
} from "./timing.js";
|
|
168
|
+
export {
|
|
169
|
+
browserWindow,
|
|
170
|
+
useDocumentVisible,
|
|
171
|
+
useGeolocation,
|
|
172
|
+
useMediaQuery,
|
|
173
|
+
useNetwork,
|
|
174
|
+
useOnline,
|
|
175
|
+
usePermission,
|
|
176
|
+
usePreferredColorScheme,
|
|
177
|
+
usePrefersReducedMotion,
|
|
178
|
+
useScrollLock,
|
|
179
|
+
useSupported,
|
|
180
|
+
useWindowScroll,
|
|
181
|
+
useWindowSize,
|
|
182
|
+
} from "./browser.js";
|
|
183
|
+
export {
|
|
184
|
+
useClickOutside,
|
|
185
|
+
useElementRef,
|
|
186
|
+
useElementSize,
|
|
187
|
+
useElementState,
|
|
188
|
+
useEventListener,
|
|
189
|
+
useFocusWithin,
|
|
190
|
+
useHover,
|
|
191
|
+
useIntersecting,
|
|
192
|
+
useLongPress,
|
|
193
|
+
useMutationObserver,
|
|
194
|
+
useScroll,
|
|
195
|
+
} from "./dom.js";
|
|
196
|
+
export { useKeyCombo, useKeyHeld } from "./keyboard.js";
|
|
197
|
+
export { useBroadcast, useClipboard } from "./channels.js";
|
|
198
|
+
export {
|
|
199
|
+
useCounter,
|
|
200
|
+
useCycle,
|
|
201
|
+
useList,
|
|
202
|
+
useSet,
|
|
203
|
+
useStorage,
|
|
204
|
+
useToggle,
|
|
205
|
+
useUndoable,
|
|
206
|
+
} from "./state.js";
|
package/keyboard.js
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/keyboard`: a chord is not an event.
|
|
4
|
+
//
|
|
5
|
+
// This is the third subject in the package that is neither one element nor the
|
|
6
|
+
// ambient environment, and it earns a file because none of the difficulty is
|
|
7
|
+
// in the listening. `document.addEventListener("keydown", …)` is one line; the
|
|
8
|
+
// four things after it are what people get wrong, and they get them wrong the
|
|
9
|
+
// same way in every project:
|
|
10
|
+
//
|
|
11
|
+
// **A combination is not a key.** `⌘K` is a `keydown` whose `key` is `"k"` and
|
|
12
|
+
// whose `metaKey` is true, and a handler that checks only the first fires on a
|
|
13
|
+
// plain `k` — so a search box opens while somebody is typing a name. Checking
|
|
14
|
+
// the modifiers that *are* named is half of it; refusing the ones that are not
|
|
15
|
+
// is the other half, and it is the half that gets left out.
|
|
16
|
+
//
|
|
17
|
+
// **`mod` is not `ctrl`.** The shortcut is ⌘K on a Mac and Ctrl+K everywhere
|
|
18
|
+
// else. A library that makes the caller write two bindings makes every caller
|
|
19
|
+
// write the same platform test.
|
|
20
|
+
//
|
|
21
|
+
// **A shortcut inside a text field is a keystroke.** `?` should open help,
|
|
22
|
+
// except while somebody is typing a question mark into a comment. The default
|
|
23
|
+
// here is that a chord does not fire while the reader is typing, and a caller
|
|
24
|
+
// who means it to says so.
|
|
25
|
+
//
|
|
26
|
+
// **A held key repeats.** Holding a key sends `keydown` twenty times a second.
|
|
27
|
+
// A shortcut that opens a dialog should open it once.
|
|
28
|
+
//
|
|
29
|
+
// # What belongs in this module
|
|
30
|
+
//
|
|
31
|
+
// A hook whose subject is what is being pressed: a chord, and whether a key is
|
|
32
|
+
// down. Not the key events of one element — `useEventListener(ref, "keydown",
|
|
33
|
+
// …)` in `dom.js` is that, and it is the right tool when the subject really is
|
|
34
|
+
// the element. These listen on the document by default, because a shortcut
|
|
35
|
+
// belongs to the page rather than to whatever happens to have focus.
|
|
36
|
+
//
|
|
37
|
+
// # Before hydration
|
|
38
|
+
//
|
|
39
|
+
// Nothing. Both hooks do their work in effects, `useKeyHeld` reports `false`
|
|
40
|
+
// on a server and in the first client render, and the platform test that
|
|
41
|
+
// decides what `mod` means is made inside the effect rather than during a
|
|
42
|
+
// render — so nothing here can differ between the two passes React compares.
|
|
43
|
+
|
|
44
|
+
import { useEffect, useState } from "@uniflowed/react";
|
|
45
|
+
|
|
46
|
+
import { browserWindow } from "./browser.js";
|
|
47
|
+
import type { Ref } from "./dom.js";
|
|
48
|
+
import { useStableCallback } from "./lifecycle.js";
|
|
49
|
+
|
|
50
|
+
/** A combination, once its spelling has been resolved. */
|
|
51
|
+
type Chord = {|
|
|
52
|
+
readonly key: string,
|
|
53
|
+
readonly ctrl: boolean,
|
|
54
|
+
readonly meta: boolean,
|
|
55
|
+
readonly alt: boolean,
|
|
56
|
+
readonly shift: boolean,
|
|
57
|
+
/** Ctrl, or Command on Apple platforms. */
|
|
58
|
+
readonly mod: boolean,
|
|
59
|
+
|};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The spellings people actually write, mapped to the one `KeyboardEvent.key`
|
|
63
|
+
* uses.
|
|
64
|
+
*
|
|
65
|
+
* `key` reports the character produced, so the arrows are `"ArrowUp"` and the
|
|
66
|
+
* space bar is a literal space — neither of which anybody writes in a
|
|
67
|
+
* shortcut.
|
|
68
|
+
*/
|
|
69
|
+
const KEY_ALIASES: { readonly [string]: string } = {
|
|
70
|
+
esc: "escape",
|
|
71
|
+
space: " ",
|
|
72
|
+
spacebar: " ",
|
|
73
|
+
ret: "enter",
|
|
74
|
+
return: "enter",
|
|
75
|
+
up: "arrowup",
|
|
76
|
+
down: "arrowdown",
|
|
77
|
+
left: "arrowleft",
|
|
78
|
+
right: "arrowright",
|
|
79
|
+
del: "delete",
|
|
80
|
+
plus: "+",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read `"mod+shift+k"` into a chord.
|
|
85
|
+
*
|
|
86
|
+
* Unknown modifier names are treated as the key rather than rejected: a typo
|
|
87
|
+
* produces a shortcut that never fires, which the author notices, and a throw
|
|
88
|
+
* during a render would take the page down instead.
|
|
89
|
+
*/
|
|
90
|
+
function parseChord(combo: string): Chord {
|
|
91
|
+
let ctrl = false;
|
|
92
|
+
let meta = false;
|
|
93
|
+
let alt = false;
|
|
94
|
+
let shift = false;
|
|
95
|
+
let mod = false;
|
|
96
|
+
let key = "";
|
|
97
|
+
|
|
98
|
+
for (const raw of combo.split("+")) {
|
|
99
|
+
const part = raw.trim().toLowerCase();
|
|
100
|
+
if (part === "") {
|
|
101
|
+
// `"shift++"` is shift and the plus key, and splitting leaves a hole.
|
|
102
|
+
key = "+";
|
|
103
|
+
} else if (part === "ctrl" || part === "control") {
|
|
104
|
+
ctrl = true;
|
|
105
|
+
} else if (part === "cmd" || part === "command" || part === "meta" || part === "super") {
|
|
106
|
+
meta = true;
|
|
107
|
+
} else if (part === "alt" || part === "opt" || part === "option") {
|
|
108
|
+
alt = true;
|
|
109
|
+
} else if (part === "shift") {
|
|
110
|
+
shift = true;
|
|
111
|
+
} else if (part === "mod") {
|
|
112
|
+
mod = true;
|
|
113
|
+
} else {
|
|
114
|
+
key = KEY_ALIASES[part] ?? part;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { key, ctrl, meta, alt, shift, mod };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether `mod` means Command here.
|
|
123
|
+
*
|
|
124
|
+
* Read from the user agent because that is the only thing every browser
|
|
125
|
+
* agrees on: `navigator.platform` is deprecated and frozen, and
|
|
126
|
+
* `userAgentData` exists in Chromium alone. Called from inside an effect, so a
|
|
127
|
+
* server render never asks.
|
|
128
|
+
*/
|
|
129
|
+
function onApple(): boolean {
|
|
130
|
+
const agent = browserWindow()?.navigator.userAgent ?? "";
|
|
131
|
+
return /mac|iphone|ipad|ipod/i.test(agent);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether the event landed in something the reader is typing into. */
|
|
135
|
+
function typing(target: EventTarget): boolean {
|
|
136
|
+
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (target.isContentEditable) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
const tag = target.tagName.toLowerCase();
|
|
143
|
+
return tag === "input" || tag === "textarea" || tag === "select";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether a key is one that shift is needed to type.
|
|
148
|
+
*
|
|
149
|
+
* A single character that is neither a letter nor a digit: `"?"`, `"+"`,
|
|
150
|
+
* `"!"`. `KeyboardEvent.key` reports the character *produced*, so those arrive
|
|
151
|
+
* with `shiftKey` true on a US layout and false on layouts where they have
|
|
152
|
+
* their own key.
|
|
153
|
+
*/
|
|
154
|
+
function shiftedSymbol(key: string): boolean {
|
|
155
|
+
return key.length === 1 && !/[a-z0-9]/.test(key);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether `event` is this chord.
|
|
160
|
+
*
|
|
161
|
+
* The modifiers named must be down and the ones not named must be up. That
|
|
162
|
+
* second half is what makes `"mod+k"` refuse Ctrl+Shift+K, which is a
|
|
163
|
+
* different shortcut somebody else has probably bound.
|
|
164
|
+
*
|
|
165
|
+
* Shift is the one exception, and only for a key that shift is needed to
|
|
166
|
+
* type: `"?"` is the most common single-key shortcut on the web and arrives
|
|
167
|
+
* with `shiftKey` true on a US layout and false on a German one, so requiring
|
|
168
|
+
* either would make it unbindable on half the keyboards in the world. For a
|
|
169
|
+
* letter, a digit or a named key it is checked like the rest.
|
|
170
|
+
*/
|
|
171
|
+
function isChord(chord: Chord, event: KeyboardEvent, apple: boolean): boolean {
|
|
172
|
+
if (event.key.toLowerCase() !== chord.key) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
const meta = chord.meta || (chord.mod && apple);
|
|
176
|
+
const ctrl = chord.ctrl || (chord.mod && !apple);
|
|
177
|
+
if (event.metaKey !== meta || event.ctrlKey !== ctrl || event.altKey !== chord.alt) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (chord.shift) {
|
|
181
|
+
return event.shiftKey;
|
|
182
|
+
}
|
|
183
|
+
return shiftedSymbol(chord.key) || !event.shiftKey;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `event` as a keyboard event, or `null` for anything else. */
|
|
187
|
+
function asKeyboardEvent(event: Event): KeyboardEvent | null {
|
|
188
|
+
if (typeof KeyboardEvent === "undefined" || !(event instanceof KeyboardEvent)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return event;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** How a chord is listened for. */
|
|
195
|
+
export type KeyComboOptions = {|
|
|
196
|
+
/** Listen on this element instead of the document. */
|
|
197
|
+
readonly target?: Ref<HTMLElement> | null,
|
|
198
|
+
/** Fire even while the reader is typing into a field. Off by default. */
|
|
199
|
+
readonly whileTyping?: boolean,
|
|
200
|
+
/** Fire again while the key is held down. Off by default. */
|
|
201
|
+
readonly repeat?: boolean,
|
|
202
|
+
/** Call `preventDefault` when it fires. On by default, since ⌘K is the browser's too. */
|
|
203
|
+
readonly preventDefault?: boolean,
|
|
204
|
+
/** Turn the binding off without changing where the hook is called. */
|
|
205
|
+
readonly enabled?: boolean,
|
|
206
|
+
|};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Call `handler` when a key combination is pressed.
|
|
210
|
+
*
|
|
211
|
+
* ```js
|
|
212
|
+
* useKeyCombo("mod+k", () => setSearchOpen(true));
|
|
213
|
+
* useKeyCombo("escape", close, { whileTyping: true });
|
|
214
|
+
* ```
|
|
215
|
+
*
|
|
216
|
+
* `preventDefault` defaults to on because the combinations worth binding are
|
|
217
|
+
* the ones the browser also wants — ⌘K is the address bar in Chrome, ⌘S is
|
|
218
|
+
* Save Page — and a shortcut that fires *and* opens a browser dialog is worse
|
|
219
|
+
* than either alone. A chord that is only the application's, like `escape`,
|
|
220
|
+
* loses nothing by it.
|
|
221
|
+
*/
|
|
222
|
+
export hook useKeyCombo(
|
|
223
|
+
combo: string,
|
|
224
|
+
handler: (event: KeyboardEvent) => mixed,
|
|
225
|
+
options?: KeyComboOptions,
|
|
226
|
+
): void {
|
|
227
|
+
const stable = useStableCallback(handler);
|
|
228
|
+
const target = options?.target ?? null;
|
|
229
|
+
const whileTyping = options?.whileTyping ?? false;
|
|
230
|
+
const repeat = options?.repeat ?? false;
|
|
231
|
+
const preventDefault = options?.preventDefault ?? true;
|
|
232
|
+
const enabled = options?.enabled ?? true;
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const win = browserWindow();
|
|
236
|
+
if (!enabled || win == null) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// A ref that is given and empty means the element is not there yet, which
|
|
240
|
+
// is not the same as "no element was asked for": falling back to the
|
|
241
|
+
// document would bind the shortcut to the whole page by accident.
|
|
242
|
+
const node = target == null ? win.document : target.current;
|
|
243
|
+
if (node == null) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const chord = parseChord(combo);
|
|
248
|
+
const apple = onApple();
|
|
249
|
+
|
|
250
|
+
const listener = (event: Event) => {
|
|
251
|
+
const key = asKeyboardEvent(event);
|
|
252
|
+
if (key == null || (!repeat && key.repeat)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!whileTyping && typing(key.target)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!isChord(chord, key, apple)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (preventDefault) {
|
|
262
|
+
key.preventDefault();
|
|
263
|
+
}
|
|
264
|
+
stable(key);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
node.addEventListener("keydown", listener);
|
|
268
|
+
return () => node.removeEventListener("keydown", listener);
|
|
269
|
+
}, [combo, enabled, target, whileTyping, repeat, preventDefault, stable]);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether a key is being held down.
|
|
274
|
+
*
|
|
275
|
+
* For the case a chord cannot express: a modifier that changes what a drag
|
|
276
|
+
* does while it is held, a space bar that pans a canvas. `key` is matched
|
|
277
|
+
* against `KeyboardEvent.key`, case-insensitively, so `"shift"`, `"escape"`
|
|
278
|
+
* and `" "` all work.
|
|
279
|
+
*
|
|
280
|
+
* The `blur` reset is the reason this is a hook rather than two listeners.
|
|
281
|
+
* Holding a key and switching windows sends the `keyup` to the other window,
|
|
282
|
+
* so a hand-written version leaves the key held forever — the canvas stays
|
|
283
|
+
* panning after the reader comes back. Losing focus releases everything.
|
|
284
|
+
*/
|
|
285
|
+
export hook useKeyHeld(
|
|
286
|
+
key: string,
|
|
287
|
+
options?: {| readonly target?: Ref<HTMLElement> | null |},
|
|
288
|
+
): boolean {
|
|
289
|
+
const [held, setHeld] = useState(false);
|
|
290
|
+
const target = options?.target ?? null;
|
|
291
|
+
const wanted = key.toLowerCase();
|
|
292
|
+
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const win = browserWindow();
|
|
295
|
+
if (win == null) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const node = target == null ? win.document : target.current;
|
|
299
|
+
if (node == null) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const down = (event: Event) => {
|
|
304
|
+
const pressed = asKeyboardEvent(event);
|
|
305
|
+
if (pressed != null && pressed.key.toLowerCase() === wanted) {
|
|
306
|
+
setHeld(true);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
const up = (event: Event) => {
|
|
310
|
+
const released = asKeyboardEvent(event);
|
|
311
|
+
if (released != null && released.key.toLowerCase() === wanted) {
|
|
312
|
+
setHeld(false);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
const release = () => setHeld(false);
|
|
316
|
+
|
|
317
|
+
node.addEventListener("keydown", down);
|
|
318
|
+
node.addEventListener("keyup", up);
|
|
319
|
+
win.addEventListener("blur", release);
|
|
320
|
+
return () => {
|
|
321
|
+
node.removeEventListener("keydown", down);
|
|
322
|
+
node.removeEventListener("keyup", up);
|
|
323
|
+
win.removeEventListener("blur", release);
|
|
324
|
+
};
|
|
325
|
+
}, [wanted, target]);
|
|
326
|
+
|
|
327
|
+
return held;
|
|
328
|
+
}
|
package/lifecycle.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/hooks/lifecycle`: where a component is in its life.
|
|
4
|
+
//
|
|
5
|
+
// The one that matters most is `useStableCallback`. A callback recreated every
|
|
6
|
+
// render is the single most common cause of a React performance problem and of
|
|
7
|
+
// a subscription that tears itself down and sets itself up on every keystroke —
|
|
8
|
+
// and the usual fix, listing the callback in a dependency array, spreads the
|
|
9
|
+
// problem to every hook that takes it. A stable identity that always calls the
|
|
10
|
+
// latest closure fixes it once.
|
|
11
|
+
//
|
|
12
|
+
// # What belongs in this module
|
|
13
|
+
//
|
|
14
|
+
// A hook whose subject is the component itself: has it mounted, is it still
|
|
15
|
+
// mounted, what did it render last time, run this once, run this on the way
|
|
16
|
+
// out, and the two effect-shaped primitives the rest of the package needs
|
|
17
|
+
// (`useIsomorphicLayoutEffect`, `useStableCallback`). None of them reads
|
|
18
|
+
// anything outside React.
|
|
19
|
+
//
|
|
20
|
+
// It is also the one module here the others import, which is a consequence and
|
|
21
|
+
// not the reason: `timing.js`, `state.js` and `dom.js` all need a stable
|
|
22
|
+
// callback. That does not make this a "utils" or a "common" — it has a subject
|
|
23
|
+
// of its own, and a hook lands here because it is about the component's life,
|
|
24
|
+
// never because two other files happened to want it.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
useCallback,
|
|
28
|
+
useEffect,
|
|
29
|
+
useInsertionEffect,
|
|
30
|
+
useLayoutEffect,
|
|
31
|
+
useRef,
|
|
32
|
+
useState,
|
|
33
|
+
} from "@uniflowed/react";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `useLayoutEffect` in the browser, `useEffect` on the server.
|
|
37
|
+
*
|
|
38
|
+
* uf prerenders every static route, and React warns that `useLayoutEffect`
|
|
39
|
+
* does nothing during a server render — correctly, because there is no layout
|
|
40
|
+
* to read. Every hook here that measures or subscribes uses this, so a hook is
|
|
41
|
+
* not a reason a page cannot be prerendered.
|
|
42
|
+
*/
|
|
43
|
+
export const useIsomorphicLayoutEffect: typeof useLayoutEffect =
|
|
44
|
+
typeof globalThis.document === "undefined" ? useEffect : useLayoutEffect;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A callback whose identity never changes and whose body is always the latest.
|
|
48
|
+
*
|
|
49
|
+
* This is the `useEvent` shape from React's own RFC. The ref is written in an
|
|
50
|
+
* insertion effect rather than in the render, because writing it during render
|
|
51
|
+
* makes the callback's behaviour depend on whether that render was thrown away
|
|
52
|
+
* — and it is written before any layout effect runs, so a subscription set up
|
|
53
|
+
* in one already sees the current body.
|
|
54
|
+
*/
|
|
55
|
+
export hook useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
|
|
56
|
+
callback: (...args: TArgs) => TReturn,
|
|
57
|
+
): (...args: TArgs) => TReturn {
|
|
58
|
+
const latest = useRef(callback);
|
|
59
|
+
|
|
60
|
+
useInsertionEffect(() => {
|
|
61
|
+
latest.current = callback;
|
|
62
|
+
}, [callback]);
|
|
63
|
+
|
|
64
|
+
return useCallback((...args: TArgs) => latest.current(...args), []);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The value from the previous render, or `undefined` on the first. */
|
|
68
|
+
export hook usePrevious<T>(value: T): T | void {
|
|
69
|
+
const previous = useRef<T | void>(undefined);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
previous.current = value;
|
|
72
|
+
}, [value]);
|
|
73
|
+
return previous.current;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the component has mounted.
|
|
78
|
+
*
|
|
79
|
+
* For the case where a value differs between server and client and rendering
|
|
80
|
+
* the client's on the first pass would be a hydration mismatch: render the
|
|
81
|
+
* server's, then switch.
|
|
82
|
+
*/
|
|
83
|
+
export hook useMounted(): boolean {
|
|
84
|
+
const [mounted, setMounted] = useState(false);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
setMounted(true);
|
|
87
|
+
}, []);
|
|
88
|
+
return mounted;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Run `body` once, after mount. */
|
|
92
|
+
export hook useMount(body: () => mixed): void {
|
|
93
|
+
const stable = useStableCallback(body);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
stable();
|
|
96
|
+
}, [stable]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Run `body` once, at unmount. */
|
|
100
|
+
export hook useUnmount(body: () => mixed): void {
|
|
101
|
+
const stable = useStableCallback(body);
|
|
102
|
+
useEffect(() => () => void stable(), [stable]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Force a re-render.
|
|
107
|
+
*
|
|
108
|
+
* A counter rather than a boolean, because two renders in a row must both
|
|
109
|
+
* change the state or React drops the second.
|
|
110
|
+
*/
|
|
111
|
+
export hook useRerender(): () => void {
|
|
112
|
+
const [, setTick] = useState(0);
|
|
113
|
+
return useCallback(() => setTick((tick) => tick + 1), []);
|
|
114
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/hooks",
|
|
3
|
+
"version": "0.0.0-alpha.10",
|
|
4
|
+
"description": "The React hooks an application writes anyway, prerender-safe, part of the Unified Toolchain for Flow.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/hooks"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./async": "./async.js",
|
|
16
|
+
"./browser": "./browser.js",
|
|
17
|
+
"./channels": "./channels.js",
|
|
18
|
+
"./dom": "./dom.js",
|
|
19
|
+
"./keyboard": "./keyboard.js",
|
|
20
|
+
"./lifecycle": "./lifecycle.js",
|
|
21
|
+
"./state": "./state.js",
|
|
22
|
+
"./timing": "./timing.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"*.js"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@uniflowed/react": "0.0.0-alpha.10"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=19"
|
|
32
|
+
}
|
|
33
|
+
}
|