@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/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 function useStableCallback<TArgs extends $ReadOnlyArray<mixed>, TReturn>(
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 function usePrevious<T>(value: T): T | void {
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 function useMounted(): boolean {
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 function useMount(body: () => mixed): void {
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 function useUnmount(body: () => mixed): void {
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 function useRerender(): () => void {
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.4",
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/react": "0.0.0-alpha.4"
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"