@remit/ui 0.0.153 → 0.0.155
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/package.json +1 -1
- package/src/components/bottom-sheet.tsx +3 -0
- package/src/components/confirm-dialog.tsx +7 -20
- package/src/components/dialog.tsx +3 -22
- package/src/components/folder-role.test.ts +59 -7
- package/src/components/folder-role.tsx +22 -19
- package/src/components/mobile-search-view.stories.tsx +1 -1
- package/src/components/overlay-escape.test.ts +135 -0
- package/src/components/popover-menu.tsx +8 -8
- package/src/components/quarantine-entry-row.tsx +3 -2
- package/src/components/quarantine-fixtures.ts +6 -1
- package/src/components/quarantine-report.ts +6 -0
- package/src/components/quarantine-section.render.test.ts +13 -0
- package/src/components/rich-text-correction-menu.tsx +13 -16
- package/src/components/search-chip-input.tsx +5 -0
- package/src/components/search-results.render.test.ts +8 -2
- package/src/components/search-results.stories.tsx +5 -2
- package/src/components/self-update-progress-overlay.tsx +4 -0
- package/src/components/slide-panel.tsx +9 -10
- package/src/index.ts +7 -1
- package/src/lib/overlay-scope.test.ts +314 -0
- package/src/lib/overlay-scope.ts +205 -0
- package/src/lib/shortcut-tree.ts +23 -7
- package/src/lib/use-triage-keyboard.ts +21 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AlertOctagon, Check, Loader2 } from "lucide-react";
|
|
2
2
|
import { type RefObject, useEffect, useRef } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
4
5
|
import { Button } from "./button.js";
|
|
5
6
|
import {
|
|
6
7
|
type UpdatePhase,
|
|
@@ -76,6 +77,9 @@ export function SelfUpdateProgressOverlay({
|
|
|
76
77
|
}: SelfUpdateProgressOverlayProps) {
|
|
77
78
|
const ref = useRef<HTMLDivElement>(null);
|
|
78
79
|
useBlockingFocus(ref);
|
|
80
|
+
// Nothing to answer: the update is running and there is no way out of it, so
|
|
81
|
+
// every shortcut is contained rather than acting on a mailbox that is gone.
|
|
82
|
+
useOverlayScope({ id: "self-update", open: true });
|
|
79
83
|
const activeIndex = phaseOrder.indexOf(phase);
|
|
80
84
|
|
|
81
85
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { X } from "lucide-react";
|
|
2
|
-
import {
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
4
5
|
|
|
5
6
|
/* ------------------------------------------------------------------ */
|
|
6
7
|
/* SlidePanel: right-edge slide-over for a focused sub-task (editing */
|
|
@@ -28,15 +29,13 @@ export function SlidePanel({
|
|
|
28
29
|
footer,
|
|
29
30
|
}: SlidePanelProps) {
|
|
30
31
|
// Escape closes the panel from anywhere inside it, which is what a dialog
|
|
31
|
-
// owes the keyboard. The scrim is a
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return () => document.removeEventListener("keydown", onKeyDown);
|
|
39
|
-
}, [isOpen, onClose]);
|
|
32
|
+
// owes the keyboard, and nothing behind it sees the press. The scrim is a
|
|
33
|
+
// pointer affordance only.
|
|
34
|
+
useOverlayScope({
|
|
35
|
+
id: "slide-panel",
|
|
36
|
+
open: isOpen,
|
|
37
|
+
answers: { back: onClose },
|
|
38
|
+
});
|
|
40
39
|
|
|
41
40
|
return (
|
|
42
41
|
<>
|
package/src/index.ts
CHANGED
|
@@ -354,7 +354,6 @@ export {
|
|
|
354
354
|
type FolderRole,
|
|
355
355
|
isVirtualFolderRole,
|
|
356
356
|
provenanceFolderLabel,
|
|
357
|
-
providerLeaf,
|
|
358
357
|
type ResultFolder,
|
|
359
358
|
roleIcon,
|
|
360
359
|
} from "./components/folder-role.js";
|
|
@@ -908,6 +907,13 @@ export {
|
|
|
908
907
|
DESKTOP_MEDIA_QUERY,
|
|
909
908
|
DESKTOP_MIN_WIDTH,
|
|
910
909
|
} from "./lib/layout-breakpoints.js";
|
|
910
|
+
export {
|
|
911
|
+
type OverlayAnswers,
|
|
912
|
+
type OverlayScopeOptions,
|
|
913
|
+
overlayStack,
|
|
914
|
+
resolveAgainstOverlays,
|
|
915
|
+
useOverlayScope,
|
|
916
|
+
} from "./lib/overlay-scope.js";
|
|
911
917
|
export {
|
|
912
918
|
derivePropertyClauses,
|
|
913
919
|
normalizeSubject,
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay stack, from the outside: what a window-level listener under an
|
|
3
|
+
* open overlay is allowed to see (#958), and what a triage layer under one is
|
|
4
|
+
* allowed to run (#959).
|
|
5
|
+
*/
|
|
6
|
+
import "@remit/test-dom";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
9
|
+
import { act, createElement, type ReactNode } from "react";
|
|
10
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
11
|
+
import {
|
|
12
|
+
type OverlayAnswers,
|
|
13
|
+
overlayStack,
|
|
14
|
+
resolveAgainstOverlays,
|
|
15
|
+
useOverlayScope,
|
|
16
|
+
} from "./overlay-scope.js";
|
|
17
|
+
import { useTriageKeyboard } from "./use-triage-keyboard.js";
|
|
18
|
+
|
|
19
|
+
let root: Root;
|
|
20
|
+
let seen: string[];
|
|
21
|
+
let listener: (event: KeyboardEvent) => void;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
const container = document.getElementById("root") as unknown as HTMLElement;
|
|
25
|
+
container.innerHTML = "";
|
|
26
|
+
root = createRoot(container);
|
|
27
|
+
seen = [];
|
|
28
|
+
listener = (event) => seen.push(event.key);
|
|
29
|
+
window.addEventListener("keydown", listener);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
window.removeEventListener("keydown", listener);
|
|
34
|
+
act(() => root.unmount());
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const press = (key: string, from: EventTarget = document.body) => {
|
|
38
|
+
act(() => {
|
|
39
|
+
from.dispatchEvent(
|
|
40
|
+
new window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const field = (id: string): HTMLInputElement =>
|
|
46
|
+
document.getElementById(id) as HTMLInputElement;
|
|
47
|
+
|
|
48
|
+
function Scope({
|
|
49
|
+
id,
|
|
50
|
+
open,
|
|
51
|
+
answers,
|
|
52
|
+
children,
|
|
53
|
+
}: {
|
|
54
|
+
id: string;
|
|
55
|
+
open: boolean;
|
|
56
|
+
answers?: OverlayAnswers;
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
}) {
|
|
59
|
+
useOverlayScope({ id, open, answers });
|
|
60
|
+
return children ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const render = (element: ReactNode) => {
|
|
64
|
+
act(() => root.render(element));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("an overlay on the stack", () => {
|
|
68
|
+
it("answers Escape itself and leaves nothing for the window to see", () => {
|
|
69
|
+
const dismissed: string[] = [];
|
|
70
|
+
render(
|
|
71
|
+
createElement(Scope, {
|
|
72
|
+
id: "sheet",
|
|
73
|
+
open: true,
|
|
74
|
+
answers: { back: () => dismissed.push("sheet") },
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
press("Escape");
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(dismissed, ["sheet"]);
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
seen,
|
|
83
|
+
[],
|
|
84
|
+
"the layer behind the overlay saw the same press",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("hands the key back once it closes", () => {
|
|
89
|
+
render(createElement(Scope, { id: "sheet", open: true, answers: {} }));
|
|
90
|
+
render(createElement(Scope, { id: "sheet", open: false, answers: {} }));
|
|
91
|
+
|
|
92
|
+
press("Escape");
|
|
93
|
+
|
|
94
|
+
assert.deepEqual(seen, ["Escape"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Both open in one render, which is the case mount order gets wrong: React
|
|
98
|
+
// runs the inner overlay's effects first, so registration order would put the
|
|
99
|
+
// drawer on top of the confirmation it contains.
|
|
100
|
+
it("is answered by the innermost overlay, however the two came to be open", () => {
|
|
101
|
+
const dismissed: string[] = [];
|
|
102
|
+
const stack = () =>
|
|
103
|
+
createElement(Scope, {
|
|
104
|
+
id: "drawer",
|
|
105
|
+
open: true,
|
|
106
|
+
answers: { back: () => dismissed.push("drawer") },
|
|
107
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test
|
|
108
|
+
children: createElement(Scope, {
|
|
109
|
+
id: "confirm",
|
|
110
|
+
open: true,
|
|
111
|
+
answers: { back: () => dismissed.push("confirm") },
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
render(stack());
|
|
116
|
+
press("Escape");
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
dismissed,
|
|
119
|
+
["confirm"],
|
|
120
|
+
"the drawer under the confirmation answered for it",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
overlayStack().map((frame) => frame.id),
|
|
125
|
+
["drawer", "confirm"],
|
|
126
|
+
"the stack is not ordered outside-in",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("says what it answers without leaving the stack to say it", () => {
|
|
131
|
+
const rung: string[] = [];
|
|
132
|
+
const scope = (serving: boolean) =>
|
|
133
|
+
createElement(Scope, {
|
|
134
|
+
id: "drawer",
|
|
135
|
+
open: true,
|
|
136
|
+
answers: serving
|
|
137
|
+
? {
|
|
138
|
+
back: () => rung.push("back"),
|
|
139
|
+
toggleIntelligence: () => rung.push("toggleIntelligence"),
|
|
140
|
+
}
|
|
141
|
+
: { back: () => rung.push("back") },
|
|
142
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test
|
|
143
|
+
children: createElement(Scope, {
|
|
144
|
+
id: "confirm",
|
|
145
|
+
open: true,
|
|
146
|
+
answers: { back: () => rung.push("confirm") },
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
render(scope(false));
|
|
151
|
+
render(scope(true));
|
|
152
|
+
|
|
153
|
+
press("Escape");
|
|
154
|
+
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
rung,
|
|
157
|
+
["confirm"],
|
|
158
|
+
"changing what the drawer answers moved it above the confirmation",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("keeps serving the key that opened it, and nothing else", () => {
|
|
163
|
+
const rung: string[] = [];
|
|
164
|
+
render(
|
|
165
|
+
createElement(Scope, {
|
|
166
|
+
id: "drawer",
|
|
167
|
+
open: true,
|
|
168
|
+
answers: {
|
|
169
|
+
back: () => rung.push("back"),
|
|
170
|
+
toggleIntelligence: () => rung.push("toggleIntelligence"),
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
press("i");
|
|
176
|
+
press("j");
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(rung, ["toggleIntelligence"]);
|
|
179
|
+
// Only what the drawer answered is swallowed. A key it does not serve is
|
|
180
|
+
// left to travel; what stops it is the triage layer declining to run it.
|
|
181
|
+
assert.deepEqual(seen, ["j"], "the drawer ate a key it never answered");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("is not answered by a key typed into a field inside it", () => {
|
|
185
|
+
const rung: string[] = [];
|
|
186
|
+
render(
|
|
187
|
+
createElement(Scope, {
|
|
188
|
+
id: "drawer",
|
|
189
|
+
open: true,
|
|
190
|
+
answers: {
|
|
191
|
+
back: () => rung.push("back"),
|
|
192
|
+
toggleIntelligence: () => rung.push("toggleIntelligence"),
|
|
193
|
+
},
|
|
194
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test
|
|
195
|
+
children: createElement("input", { id: "typing" }),
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
press("i", field("typing"));
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
rung,
|
|
203
|
+
[],
|
|
204
|
+
"a letter typed into the field closed the drawer",
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("yields Escape to a control inside it that owns one", () => {
|
|
209
|
+
const dismissed: string[] = [];
|
|
210
|
+
render(
|
|
211
|
+
createElement(Scope, {
|
|
212
|
+
id: "sheet",
|
|
213
|
+
open: true,
|
|
214
|
+
answers: { back: () => dismissed.push("sheet") },
|
|
215
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test
|
|
216
|
+
children: createElement("input", {
|
|
217
|
+
"data-escape-owner": "",
|
|
218
|
+
id: "suggesting",
|
|
219
|
+
}),
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
(document.getElementById("suggesting") as HTMLInputElement).focus();
|
|
223
|
+
|
|
224
|
+
press("Escape");
|
|
225
|
+
|
|
226
|
+
assert.deepEqual(dismissed, [], "the overlay took Escape from the field");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("what the surfaces under an overlay may act on", () => {
|
|
231
|
+
it("contains an action the overlay does not serve", () => {
|
|
232
|
+
render(createElement(Scope, { id: "sheet", open: true, answers: {} }));
|
|
233
|
+
|
|
234
|
+
assert.equal(resolveAgainstOverlays("compose")?.outcome, "contained");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("resolves to nothing at all with no overlay up", () => {
|
|
238
|
+
assert.equal(resolveAgainstOverlays("compose"), null);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("leaves c and i typed into a field under an overlay inert (#959)", () => {
|
|
242
|
+
const ran: string[] = [];
|
|
243
|
+
function Layer() {
|
|
244
|
+
useTriageKeyboard({
|
|
245
|
+
handlers: {
|
|
246
|
+
compose: () => ran.push("compose"),
|
|
247
|
+
toggleIntelligence: () => ran.push("toggleIntelligence"),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
return createElement(Scope, {
|
|
251
|
+
id: "sheet",
|
|
252
|
+
open: true,
|
|
253
|
+
answers: { back: () => undefined },
|
|
254
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test
|
|
255
|
+
children: createElement("input", { id: "typing" }),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
render(createElement(Layer));
|
|
260
|
+
|
|
261
|
+
press("c", field("typing"));
|
|
262
|
+
press("i", field("typing"));
|
|
263
|
+
|
|
264
|
+
assert.deepEqual(
|
|
265
|
+
ran,
|
|
266
|
+
[],
|
|
267
|
+
"typing under an overlay reached the layer below",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("drops a g prefix rather than arming one behind the overlay", () => {
|
|
272
|
+
const went: string[] = [];
|
|
273
|
+
function Layer({ modal }: { modal: boolean }) {
|
|
274
|
+
useTriageKeyboard({ handlers: { goBrief: () => went.push("goBrief") } });
|
|
275
|
+
return createElement(Scope, {
|
|
276
|
+
id: "sheet",
|
|
277
|
+
open: modal,
|
|
278
|
+
answers: { back: () => undefined },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// `g` over the modal must not leave a prefix behind for the `b` that
|
|
283
|
+
// follows it, which lands after the overlay has gone.
|
|
284
|
+
render(createElement(Layer, { modal: true }));
|
|
285
|
+
press("g");
|
|
286
|
+
render(createElement(Layer, { modal: false }));
|
|
287
|
+
press("b");
|
|
288
|
+
assert.deepEqual(went, [], "a sequence completed across the modal");
|
|
289
|
+
|
|
290
|
+
press("g");
|
|
291
|
+
press("b");
|
|
292
|
+
assert.deepEqual(went, ["goBrief"], "g stayed dead after the modal closed");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("leaves a triage layer's compose inert under a modal (#959)", () => {
|
|
296
|
+
const composed: string[] = [];
|
|
297
|
+
function Layer({ modal }: { modal: boolean }) {
|
|
298
|
+
useTriageKeyboard({ handlers: { compose: () => composed.push("c") } });
|
|
299
|
+
return createElement(Scope, {
|
|
300
|
+
id: "confirm",
|
|
301
|
+
open: modal,
|
|
302
|
+
answers: { back: () => undefined },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
render(createElement(Layer, { modal: true }));
|
|
307
|
+
press("c");
|
|
308
|
+
assert.deepEqual(composed, [], "c opened compose from under the modal");
|
|
309
|
+
|
|
310
|
+
render(createElement(Layer, { modal: false }));
|
|
311
|
+
press("c");
|
|
312
|
+
assert.deepEqual(composed, ["c"], "c stayed dead after the modal closed");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live overlay stack — the runtime half of the shortcut tree's leaf.
|
|
3
|
+
*
|
|
4
|
+
* `shortcut-tree` already states the rule: the top overlay frame either answers
|
|
5
|
+
* an action or contains it, and a key pressed over an overlay never reaches the
|
|
6
|
+
* surface behind it. Nothing enforced that at runtime, so every overlay grew its
|
|
7
|
+
* own answer — a capture-phase window listener here, a document-phase
|
|
8
|
+
* `stopPropagation` there, a `blocksKeyboard` flag threaded up through a pane —
|
|
9
|
+
* and the surfaces that grew none let Escape close the conversation underneath
|
|
10
|
+
* (#958) while `c` opened compose out from under a modal (#959).
|
|
11
|
+
*
|
|
12
|
+
* A mounted overlay declares itself here for as long as it is on screen, with
|
|
13
|
+
* what it answers for, and the rest follows from that one declaration:
|
|
14
|
+
*
|
|
15
|
+
* - What the top frame answers is run by one listener, shared by every overlay.
|
|
16
|
+
* It sits on `window` in the capture phase, ahead of the triage layers and of
|
|
17
|
+
* every other window listener the app binds, and swallows the key it ran —
|
|
18
|
+
* Escape for a dismissal, and `i` for the drawer that key opened. A control
|
|
19
|
+
* inside an overlay with something of its own to close marks itself
|
|
20
|
+
* `[data-escape-owner]` and keeps Escape while it holds focus.
|
|
21
|
+
* - Everything else is contained rather than swallowed: `useTriageKeyboard`
|
|
22
|
+
* resolves through {@link resolveAgainstOverlays} and declines to run a handler
|
|
23
|
+
* the top frame does not serve, so the key is inert instead of racing.
|
|
24
|
+
*
|
|
25
|
+
* An overlay that answers nothing — the selection wizard, which has its own Back
|
|
26
|
+
* and Close and wants Escape to do neither — declares no answers and still
|
|
27
|
+
* contains the keyboard while it is up.
|
|
28
|
+
*
|
|
29
|
+
* The stack is ordered by where each overlay sits in the React tree, taken once
|
|
30
|
+
* at its first render: an overlay is above every overlay it renders inside. That
|
|
31
|
+
* is the invariant a nested pair needs — a confirmation raised from inside a
|
|
32
|
+
* drawer is the one Escape reaches — and it holds however the two came to be
|
|
33
|
+
* open, including a remount that mounts both in one commit. Registration order
|
|
34
|
+
* cannot state it: React runs a child's effects before its parent's, so
|
|
35
|
+
* registering on mount puts the inner overlay underneath the one containing it.
|
|
36
|
+
*
|
|
37
|
+
* The stack is module state rather than a React context on purpose: a window
|
|
38
|
+
* listener is global, so the register it answers from is too, and an overlay
|
|
39
|
+
* rendered through a portal or mounted in a Storybook story needs no provider
|
|
40
|
+
* above it to be seen.
|
|
41
|
+
*/
|
|
42
|
+
import { useEffect, useRef, useState } from "react";
|
|
43
|
+
import type { TriageAction } from "./keymap.js";
|
|
44
|
+
import {
|
|
45
|
+
dispatchKey,
|
|
46
|
+
isControlTarget,
|
|
47
|
+
isEditableTarget,
|
|
48
|
+
} from "./keymap-dispatch.js";
|
|
49
|
+
import {
|
|
50
|
+
type OverlayFrame,
|
|
51
|
+
type Resolution,
|
|
52
|
+
resolveOverlays,
|
|
53
|
+
} from "./shortcut-tree.js";
|
|
54
|
+
|
|
55
|
+
/** What an overlay answers, keyed by the action it answers. */
|
|
56
|
+
export type OverlayAnswers = Partial<Record<TriageAction, () => void>>;
|
|
57
|
+
|
|
58
|
+
interface ScopeEntry {
|
|
59
|
+
id: string;
|
|
60
|
+
/** Position in the React tree, ascending outward-in. See the module note. */
|
|
61
|
+
depth: number;
|
|
62
|
+
run: () => OverlayAnswers;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ESCAPE_OWNER_SELECTOR = "[data-escape-owner]";
|
|
66
|
+
|
|
67
|
+
let entries: ScopeEntry[] = [];
|
|
68
|
+
|
|
69
|
+
/** Handed out by `useState` during render, so parents are numbered before children. */
|
|
70
|
+
let renderedOverlays = 0;
|
|
71
|
+
const nextDepth = (): number => ++renderedOverlays;
|
|
72
|
+
|
|
73
|
+
const byDepth = (a: ScopeEntry, b: ScopeEntry): number => a.depth - b.depth;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The frames on screen, root first — the tree's `overlays`.
|
|
77
|
+
*
|
|
78
|
+
* Built on demand rather than stored, so an overlay that gains or loses an
|
|
79
|
+
* answer while it is open says so without leaving the stack and rejoining it at
|
|
80
|
+
* the top.
|
|
81
|
+
*/
|
|
82
|
+
export function overlayStack(): readonly OverlayFrame[] {
|
|
83
|
+
return [...entries].sort(byDepth).map((entry) => ({
|
|
84
|
+
id: entry.id,
|
|
85
|
+
handles: Object.entries(entry.run())
|
|
86
|
+
.filter(([, answer]) => answer)
|
|
87
|
+
.map(([action]) => action as TriageAction),
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The innermost overlay on screen — the leaf that answers first. */
|
|
92
|
+
function topEntry(): ScopeEntry | undefined {
|
|
93
|
+
let top: ScopeEntry | undefined;
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!top || entry.depth > top.depth) top = entry;
|
|
96
|
+
}
|
|
97
|
+
return top;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How the open overlays answer this action, or null when none is up. A layer
|
|
102
|
+
* with a window-level keyboard consults this before running a handler of its
|
|
103
|
+
* own: any answer at all means the action belongs to the overlay.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveAgainstOverlays(
|
|
106
|
+
action: TriageAction,
|
|
107
|
+
): Resolution | null {
|
|
108
|
+
return resolveOverlays(action, overlayStack());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What this keystroke means to an overlay. Escape reaches one from anywhere
|
|
113
|
+
* inside it, a focused field included — unless a control in there has its own
|
|
114
|
+
* thing to close, which takes the press and leaves the next one for the overlay.
|
|
115
|
+
* Every other key goes through the ordinary dispatch, so `i` typed into a field
|
|
116
|
+
* inside a drawer is a letter and not a dismissal. A `g …` sequence is never an
|
|
117
|
+
* overlay's to answer, so the prefix state is not carried here.
|
|
118
|
+
*/
|
|
119
|
+
function overlayAction(event: KeyboardEvent): TriageAction | null {
|
|
120
|
+
if (event.key === "Escape") {
|
|
121
|
+
const focused = document.activeElement;
|
|
122
|
+
if (focused instanceof Element && focused.closest(ESCAPE_OWNER_SELECTOR)) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return "back";
|
|
126
|
+
}
|
|
127
|
+
return dispatchKey(
|
|
128
|
+
{
|
|
129
|
+
key: event.key,
|
|
130
|
+
shiftKey: event.shiftKey,
|
|
131
|
+
metaKey: event.metaKey,
|
|
132
|
+
ctrlKey: event.ctrlKey,
|
|
133
|
+
altKey: event.altKey,
|
|
134
|
+
inEditable: isEditableTarget(event.target),
|
|
135
|
+
onControl: isControlTarget(event.target),
|
|
136
|
+
},
|
|
137
|
+
null,
|
|
138
|
+
).action;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onOverlayKey(event: KeyboardEvent): void {
|
|
142
|
+
const top = topEntry();
|
|
143
|
+
if (!top) return;
|
|
144
|
+
const action = overlayAction(event);
|
|
145
|
+
if (!action) return;
|
|
146
|
+
const answer = top.run()[action];
|
|
147
|
+
if (!answer) return;
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
event.stopImmediatePropagation();
|
|
150
|
+
answer();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function setEntries(next: ScopeEntry[]): void {
|
|
154
|
+
const wasEmpty = entries.length === 0;
|
|
155
|
+
entries = next;
|
|
156
|
+
if (wasEmpty === (next.length === 0)) return;
|
|
157
|
+
if (next.length > 0) {
|
|
158
|
+
window.addEventListener("keydown", onOverlayKey, true);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
window.removeEventListener("keydown", onOverlayKey, true);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface OverlayScopeOptions {
|
|
165
|
+
/** Names the frame in the stack; only has to tell it from its neighbours. */
|
|
166
|
+
id: string;
|
|
167
|
+
/** On screen. A closed overlay leaves the stack and contains nothing. */
|
|
168
|
+
open: boolean;
|
|
169
|
+
/**
|
|
170
|
+
* What this overlay answers, run by the shared listener before any layer
|
|
171
|
+
* underneath sees the key. `back` is Escape, and dismissing is what a modal,
|
|
172
|
+
* a drawer and a menu all want it to mean. Every action outside the table is
|
|
173
|
+
* contained: inert for the surfaces underneath, never forwarded.
|
|
174
|
+
*/
|
|
175
|
+
answers?: OverlayAnswers;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Put this overlay on the stack while it is open. One call replaces a
|
|
180
|
+
* hand-rolled Escape listener, and hands the rest of the keyboard back to the
|
|
181
|
+
* surfaces underneath only once the overlay is gone.
|
|
182
|
+
*/
|
|
183
|
+
export function useOverlayScope({
|
|
184
|
+
id,
|
|
185
|
+
open,
|
|
186
|
+
answers = {},
|
|
187
|
+
}: OverlayScopeOptions): void {
|
|
188
|
+
// Read at keystroke time, so neither a re-rendered answer nor a changed set
|
|
189
|
+
// of them re-registers the frame.
|
|
190
|
+
const answersRef = useRef(answers);
|
|
191
|
+
answersRef.current = answers;
|
|
192
|
+
|
|
193
|
+
// Numbered during the first render, where React is still going parent before
|
|
194
|
+
// child — the one moment nesting is legible from inside a hook.
|
|
195
|
+
const [depth] = useState(nextDepth);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (!open) return;
|
|
199
|
+
const entry: ScopeEntry = { id, depth, run: () => answersRef.current };
|
|
200
|
+
setEntries([...entries, entry]);
|
|
201
|
+
return () => {
|
|
202
|
+
setEntries(entries.filter((candidate) => candidate !== entry));
|
|
203
|
+
};
|
|
204
|
+
}, [id, open, depth]);
|
|
205
|
+
}
|
package/src/lib/shortcut-tree.ts
CHANGED
|
@@ -315,6 +315,27 @@ function isRegistered(
|
|
|
315
315
|
return registered[level]?.includes(action) === true;
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The overlay stack's own verdict, independent of everything below it: the top
|
|
320
|
+
* frame answers the action or contains it, and null means no overlay is up.
|
|
321
|
+
*
|
|
322
|
+
* Exported because the runtime stack (`overlay-scope`) needs the same rule from
|
|
323
|
+
* a keydown listener, where the rest of the tree is not built. One rule, one
|
|
324
|
+
* place: an overlay that contains a key for the resolver contains it for the
|
|
325
|
+
* live keyboard too.
|
|
326
|
+
*/
|
|
327
|
+
export function resolveOverlays(
|
|
328
|
+
action: TriageAction,
|
|
329
|
+
overlays: readonly OverlayFrame[],
|
|
330
|
+
): Resolution | null {
|
|
331
|
+
const frame = overlays.at(-1);
|
|
332
|
+
if (!frame) return null;
|
|
333
|
+
if (!frame.handles.includes(action)) {
|
|
334
|
+
return { outcome: "contained", by: { kind: "overlay", frame } };
|
|
335
|
+
}
|
|
336
|
+
return { outcome: "act", target: { kind: "overlay", frame } };
|
|
337
|
+
}
|
|
338
|
+
|
|
318
339
|
/**
|
|
319
340
|
* Resolve an action against the tree. Pure: no DOM, no router, no side effects.
|
|
320
341
|
*
|
|
@@ -334,13 +355,8 @@ export function resolveShortcut(
|
|
|
334
355
|
tree: ShortcutTree,
|
|
335
356
|
registered: RegisteredActions,
|
|
336
357
|
): Resolution {
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
339
|
-
if (!frame.handles.includes(action)) {
|
|
340
|
-
return { outcome: "contained", by: { kind: "overlay", frame } };
|
|
341
|
-
}
|
|
342
|
-
return { outcome: "act", target: { kind: "overlay", frame } };
|
|
343
|
-
}
|
|
358
|
+
const overlaid = resolveOverlays(action, tree.overlays);
|
|
359
|
+
if (overlaid) return overlaid;
|
|
344
360
|
|
|
345
361
|
const field = tree.editing;
|
|
346
362
|
if (field) {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isEditableTarget,
|
|
7
7
|
type SequencePrefix,
|
|
8
8
|
} from "./keymap-dispatch.js";
|
|
9
|
+
import { overlayStack, resolveAgainstOverlays } from "./overlay-scope.js";
|
|
9
10
|
|
|
10
11
|
interface UseTriageKeyboardOptions {
|
|
11
12
|
handlers: TriageHandlers;
|
|
@@ -47,6 +48,11 @@ interface UseTriageKeyboardOptions {
|
|
|
47
48
|
* conversation views. They bind disjoint keys; only the list's competing
|
|
48
49
|
* listener was removed.
|
|
49
50
|
*
|
|
51
|
+
* An open overlay pre-empts the whole layer. Every mounted modal, drawer and
|
|
52
|
+
* menu declares itself through `overlay-scope`, and each action is resolved
|
|
53
|
+
* against that stack before a handler runs, so no layer acts through a surface
|
|
54
|
+
* the reader has on top of it.
|
|
55
|
+
*
|
|
50
56
|
* Per-action targeting (focused row vs selection) and the actual mutations live
|
|
51
57
|
* in the handlers the caller passes in — this hook only dispatches.
|
|
52
58
|
*/
|
|
@@ -87,6 +93,17 @@ export function useTriageKeyboard({
|
|
|
87
93
|
prefixRef.current,
|
|
88
94
|
);
|
|
89
95
|
|
|
96
|
+
// An overlay is the leaf of the shortcut tree: while one is on screen it
|
|
97
|
+
// answers what it serves — through `overlay-scope`'s own listener, which
|
|
98
|
+
// has already run and swallowed the key — and contains the rest. The
|
|
99
|
+
// pending prefix is contained with it: a `g` pressed over a modal must
|
|
100
|
+
// not arm a sequence whose second key lands on the surface behind it.
|
|
101
|
+
if (overlayStack().length > 0) {
|
|
102
|
+
clearPrefixTimer();
|
|
103
|
+
prefixRef.current = null;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
90
107
|
// Update the pending prefix and (re)arm / clear its reset timer.
|
|
91
108
|
clearPrefixTimer();
|
|
92
109
|
prefixRef.current = result.nextPrefix;
|
|
@@ -99,6 +116,10 @@ export function useTriageKeyboard({
|
|
|
99
116
|
|
|
100
117
|
if (result.action === null) return;
|
|
101
118
|
|
|
119
|
+
// The same rule, per action: a contained key is left undefaulted, because
|
|
120
|
+
// it was never ours to consume.
|
|
121
|
+
if (resolveAgainstOverlays(result.action) !== null) return;
|
|
122
|
+
|
|
102
123
|
const handler = handlersRef.current[result.action];
|
|
103
124
|
if (!handler) return;
|
|
104
125
|
|