@remit/ui 0.0.80 → 0.0.82

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.
@@ -0,0 +1,119 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { TriageHandlers } from "./keymap.js";
3
+ import {
4
+ dispatchKey,
5
+ isControlTarget,
6
+ isEditableTarget,
7
+ type SequencePrefix,
8
+ } from "./keymap-dispatch.js";
9
+
10
+ interface UseTriageKeyboardOptions {
11
+ handlers: TriageHandlers;
12
+ /** Disable the whole layer (e.g. a blocking modal owns the keyboard). */
13
+ enabled?: boolean;
14
+ /**
15
+ * The element the layer listens on. Omitted, the layer is the page's and
16
+ * takes the window — the app's. A layer belonging to one mounted surface
17
+ * passes that surface's element, so several of them on a page (a Storybook
18
+ * docs page carrying every list story) each answer only the keys pressed
19
+ * inside their own; `null` until that element is up, and inert until then.
20
+ */
21
+ target?: HTMLElement | null;
22
+ /**
23
+ * Reset window (ms) for a pending `g …` sequence prefix. After this with no
24
+ * second key, the prefix is dropped. ~1s per the spec.
25
+ */
26
+ sequenceTimeoutMs?: number;
27
+ }
28
+
29
+ /**
30
+ * Global keydown handler for the triage layer's VERBS (#429). Routes keystrokes
31
+ * through the pure {@link dispatchKey} core to the supplied handler table,
32
+ * staying fully inert while focus is in an editable surface (input/textarea/CE;
33
+ * even Esc is left to the focused field's own handler) and carrying the `g …`
34
+ * go-to sequence prefix across keystrokes with a timeout.
35
+ *
36
+ * List navigation and selection route through here and nowhere else: the list
37
+ * publishes its commands upward and its host wires them into the handler
38
+ * table, so `keymap.ts` is the source of truth for both the displayed bindings
39
+ * and the routed ones. Every surface that mounts a list — the app and the
40
+ * Storybook prototype — drives it from here, so the interaction reviewed in
41
+ * Storybook is the one users get. The list used to run a second window listener
42
+ * claiming the same keys, which is what made Enter unusable on every focused
43
+ * button in the app (#43).
44
+ *
45
+ * Other window-level keydown listeners still exist for keys this layer does not
46
+ * own — `?` at the mail layout, `/` in SearchBar, Esc in the compose and
47
+ * conversation views. They bind disjoint keys; only the list's competing
48
+ * listener was removed.
49
+ *
50
+ * Per-action targeting (focused row vs selection) and the actual mutations live
51
+ * in the handlers the caller passes in — this hook only dispatches.
52
+ */
53
+ export function useTriageKeyboard({
54
+ handlers,
55
+ enabled = true,
56
+ target,
57
+ sequenceTimeoutMs = 1000,
58
+ }: UseTriageKeyboardOptions): void {
59
+ // Latest handlers without re-subscribing the listener every render.
60
+ const handlersRef = useRef(handlers);
61
+ handlersRef.current = handlers;
62
+
63
+ const prefixRef = useRef<SequencePrefix>(null);
64
+ const prefixTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
65
+
66
+ useEffect(() => {
67
+ if (!enabled) return;
68
+
69
+ const clearPrefixTimer = () => {
70
+ if (prefixTimerRef.current !== null) {
71
+ clearTimeout(prefixTimerRef.current);
72
+ prefixTimerRef.current = null;
73
+ }
74
+ };
75
+
76
+ const onKeyDown = (event: KeyboardEvent) => {
77
+ const result = dispatchKey(
78
+ {
79
+ key: event.key,
80
+ shiftKey: event.shiftKey,
81
+ metaKey: event.metaKey,
82
+ ctrlKey: event.ctrlKey,
83
+ altKey: event.altKey,
84
+ inEditable: isEditableTarget(event.target),
85
+ onControl: isControlTarget(event.target),
86
+ },
87
+ prefixRef.current,
88
+ );
89
+
90
+ // Update the pending prefix and (re)arm / clear its reset timer.
91
+ clearPrefixTimer();
92
+ prefixRef.current = result.nextPrefix;
93
+ if (result.nextPrefix !== null) {
94
+ prefixTimerRef.current = setTimeout(() => {
95
+ prefixRef.current = null;
96
+ prefixTimerRef.current = null;
97
+ }, sequenceTimeoutMs);
98
+ }
99
+
100
+ if (result.action === null) return;
101
+
102
+ const handler = handlersRef.current[result.action];
103
+ if (!handler) return;
104
+
105
+ if (result.preventDefault) event.preventDefault();
106
+ handler();
107
+ };
108
+
109
+ const element: EventTarget | null = target === undefined ? window : target;
110
+ if (element === null) return;
111
+
112
+ element.addEventListener("keydown", onKeyDown as EventListener);
113
+ return () => {
114
+ element.removeEventListener("keydown", onKeyDown as EventListener);
115
+ clearPrefixTimer();
116
+ prefixRef.current = null;
117
+ };
118
+ }, [enabled, sequenceTimeoutMs, target]);
119
+ }