@uniflowed/hooks 0.0.0-alpha.4 → 0.0.0-alpha.41
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 +99 -16
- package/browser.js +934 -60
- package/channels.js +224 -0
- package/dom.js +262 -58
- package/events.js +300 -0
- package/index.js +176 -21
- package/keyboard.js +328 -0
- package/lifecycle.js +12 -6
- package/package.json +9 -3
- package/render.js +354 -0
- package/state.js +378 -31
- package/timing.js +332 -17
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
CHANGED
|
@@ -52,7 +52,7 @@ export const useIsomorphicLayoutEffect: typeof useLayoutEffect =
|
|
|
52
52
|
* — and it is written before any layout effect runs, so a subscription set up
|
|
53
53
|
* in one already sees the current body.
|
|
54
54
|
*/
|
|
55
|
-
export
|
|
55
|
+
export hook useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
|
|
56
56
|
callback: (...args: TArgs) => TReturn,
|
|
57
57
|
): (...args: TArgs) => TReturn {
|
|
58
58
|
const latest = useRef(callback);
|
|
@@ -65,11 +65,14 @@ export function useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** The value from the previous render, or `undefined` on the first. */
|
|
68
|
-
export
|
|
68
|
+
export hook usePrevious<T>(value: T): T | void {
|
|
69
69
|
const previous = useRef<T | void>(undefined);
|
|
70
70
|
useEffect(() => {
|
|
71
71
|
previous.current = value;
|
|
72
72
|
}, [value]);
|
|
73
|
+
// This hook's public value is the last committed render; reading the ref
|
|
74
|
+
// during render is the contract rather than hidden reactive input.
|
|
75
|
+
// uf-lint-disable-next-line react-compiler/refs
|
|
73
76
|
return previous.current;
|
|
74
77
|
}
|
|
75
78
|
|
|
@@ -80,16 +83,19 @@ export function usePrevious<T>(value: T): T | void {
|
|
|
80
83
|
* the client's on the first pass would be a hydration mismatch: render the
|
|
81
84
|
* server's, then switch.
|
|
82
85
|
*/
|
|
83
|
-
export
|
|
86
|
+
export hook useMounted(): boolean {
|
|
84
87
|
const [mounted, setMounted] = useState(false);
|
|
85
88
|
useEffect(() => {
|
|
89
|
+
// The first client render intentionally matches the server, then flips
|
|
90
|
+
// after mount for hydration-sensitive values.
|
|
91
|
+
// uf-lint-disable-next-line react-compiler/set-state-in-effect
|
|
86
92
|
setMounted(true);
|
|
87
93
|
}, []);
|
|
88
94
|
return mounted;
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
/** Run `body` once, after mount. */
|
|
92
|
-
export
|
|
98
|
+
export hook useMount(body: () => mixed): void {
|
|
93
99
|
const stable = useStableCallback(body);
|
|
94
100
|
useEffect(() => {
|
|
95
101
|
stable();
|
|
@@ -97,7 +103,7 @@ export function useMount(body: () => mixed): void {
|
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
/** Run `body` once, at unmount. */
|
|
100
|
-
export
|
|
106
|
+
export hook useUnmount(body: () => mixed): void {
|
|
101
107
|
const stable = useStableCallback(body);
|
|
102
108
|
useEffect(() => () => void stable(), [stable]);
|
|
103
109
|
}
|
|
@@ -108,7 +114,7 @@ export function useUnmount(body: () => mixed): void {
|
|
|
108
114
|
* A counter rather than a boolean, because two renders in a row must both
|
|
109
115
|
* change the state or React drops the second.
|
|
110
116
|
*/
|
|
111
|
-
export
|
|
117
|
+
export hook useRerender(): () => void {
|
|
112
118
|
const [, setTick] = useState(0);
|
|
113
119
|
return useCallback(() => setTick((tick) => tick + 1), []);
|
|
114
120
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/hooks",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.41",
|
|
4
4
|
"description": "The React hooks an application writes anyway, prerender-safe, part of the Unified Toolchain for Flow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,16 +14,22 @@
|
|
|
14
14
|
".": "./index.js",
|
|
15
15
|
"./async": "./async.js",
|
|
16
16
|
"./browser": "./browser.js",
|
|
17
|
+
"./channels": "./channels.js",
|
|
17
18
|
"./dom": "./dom.js",
|
|
19
|
+
"./events": "./events.js",
|
|
20
|
+
"./keyboard": "./keyboard.js",
|
|
18
21
|
"./lifecycle": "./lifecycle.js",
|
|
22
|
+
"./render": "./render.js",
|
|
19
23
|
"./state": "./state.js",
|
|
20
24
|
"./timing": "./timing.js"
|
|
21
25
|
},
|
|
22
26
|
"files": [
|
|
23
|
-
"*.js"
|
|
27
|
+
"*.js",
|
|
28
|
+
"!*.test.js"
|
|
24
29
|
],
|
|
25
30
|
"dependencies": {
|
|
26
|
-
"@uniflowed/
|
|
31
|
+
"@uniflowed/core": "0.0.0-alpha.41",
|
|
32
|
+
"@uniflowed/react": "0.0.0-alpha.41"
|
|
27
33
|
},
|
|
28
34
|
"peerDependencies": {
|
|
29
35
|
"react": ">=19"
|