@remit/ui 0.0.153 → 0.0.154
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/overlay-escape.test.ts +135 -0
- package/src/components/popover-menu.tsx +8 -8
- package/src/components/rich-text-correction-menu.tsx +13 -16
- package/src/components/search-chip-input.tsx +5 -0
- package/src/components/self-update-progress-overlay.tsx +4 -0
- package/src/components/slide-panel.tsx +9 -10
- package/src/index.ts +7 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
4
5
|
|
|
5
6
|
const SNAP_MS = 320;
|
|
6
7
|
const SNAP_EASE = "cubic-bezier(0.32, 0.9, 0.3, 1)";
|
|
@@ -32,6 +33,8 @@ export function BottomSheet({
|
|
|
32
33
|
children,
|
|
33
34
|
dismissLabel = "Dismiss",
|
|
34
35
|
}: BottomSheetProps) {
|
|
36
|
+
useOverlayScope({ id: "bottom-sheet", open, answers: { back: onClose } });
|
|
37
|
+
|
|
35
38
|
const sheetRef = useRef<HTMLDivElement>(null);
|
|
36
39
|
const [height, setHeight] = useState(HEIGHT_FALLBACK);
|
|
37
40
|
const [drag, setDrag] = useState<number | null>(null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
3
4
|
import { DialogBackdrop } from "./dialog-backdrop.js";
|
|
4
5
|
|
|
5
6
|
export interface ConfirmDialogProps {
|
|
@@ -38,25 +39,11 @@ export const ConfirmDialog = ({
|
|
|
38
39
|
}: ConfirmDialogProps) => {
|
|
39
40
|
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
event.stopImmediatePropagation();
|
|
47
|
-
onCancel();
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
[onCancel],
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (!isOpen) return;
|
|
55
|
-
// Capture phase so Esc closes the dialog before any list-level Esc
|
|
56
|
-
// handler (e.g. clearSelection) also fires on the same keystroke.
|
|
57
|
-
window.addEventListener("keydown", handleKeyDown, true);
|
|
58
|
-
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
59
|
-
}, [isOpen, handleKeyDown]);
|
|
42
|
+
useOverlayScope({
|
|
43
|
+
id: "confirm-dialog",
|
|
44
|
+
open: isOpen,
|
|
45
|
+
answers: { back: onCancel },
|
|
46
|
+
});
|
|
60
47
|
|
|
61
48
|
// Whoever opened the dialog gets the focus back when it closes. Without this
|
|
62
49
|
// a cancelled confirmation drops focus to the body, and the control the user
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { type ReactNode,
|
|
1
|
+
import { type ReactNode, useEffect, useRef } from "react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
3
4
|
import { DialogBackdrop } from "./dialog-backdrop.js";
|
|
4
5
|
|
|
5
6
|
export interface DialogProps {
|
|
@@ -27,27 +28,7 @@ export function Dialog({
|
|
|
27
28
|
}: DialogProps) {
|
|
28
29
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
(e: KeyboardEvent) => {
|
|
32
|
-
if (e.key !== "Escape") return;
|
|
33
|
-
// A control inside the dialog can own Escape while it has something of
|
|
34
|
-
// its own to close — an open suggestion list. Escape closes that first;
|
|
35
|
-
// the next Escape closes the dialog.
|
|
36
|
-
const focused = document.activeElement;
|
|
37
|
-
if (focused instanceof Element && focused.closest("[data-escape-owner]"))
|
|
38
|
-
return;
|
|
39
|
-
e.preventDefault();
|
|
40
|
-
e.stopImmediatePropagation();
|
|
41
|
-
onClose();
|
|
42
|
-
},
|
|
43
|
-
[onClose],
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
useEffect(() => {
|
|
47
|
-
if (!open) return;
|
|
48
|
-
window.addEventListener("keydown", handleKeyDown, true);
|
|
49
|
-
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
50
|
-
}, [open, handleKeyDown]);
|
|
31
|
+
useOverlayScope({ id: "dialog", open, answers: { back: onClose } });
|
|
51
32
|
|
|
52
33
|
useEffect(() => {
|
|
53
34
|
if (!open) return;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape on an open overlay closes the overlay and nothing else (#958).
|
|
3
|
+
*
|
|
4
|
+
* The window-level triage layer maps Escape to `back`, which closes the
|
|
5
|
+
* conversation or the list underneath; each of these surfaces used to let the
|
|
6
|
+
* press reach it, so one keystroke dismissed two things. They share one scoping
|
|
7
|
+
* mechanism now, and the reading here is the same for all of them: a listener on
|
|
8
|
+
* `window` never sees the key the overlay answered.
|
|
9
|
+
*/
|
|
10
|
+
import "@remit/test-dom";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
13
|
+
import { act, createElement, type ReactNode } from "react";
|
|
14
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
15
|
+
import { ConfirmDialog } from "./confirm-dialog.js";
|
|
16
|
+
import { Dialog } from "./dialog.js";
|
|
17
|
+
import { PopoverMenu } from "./popover-menu.js";
|
|
18
|
+
import { SlidePanel, type SlidePanelProps } from "./slide-panel.js";
|
|
19
|
+
|
|
20
|
+
let root: Root;
|
|
21
|
+
let container: HTMLElement;
|
|
22
|
+
let seen: string[];
|
|
23
|
+
let listener: (event: KeyboardEvent) => void;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
container = document.getElementById("root") as unknown as HTMLElement;
|
|
27
|
+
container.innerHTML = "";
|
|
28
|
+
root = createRoot(container);
|
|
29
|
+
seen = [];
|
|
30
|
+
listener = (event) => seen.push(event.key);
|
|
31
|
+
window.addEventListener("keydown", listener);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
window.removeEventListener("keydown", listener);
|
|
36
|
+
act(() => root.unmount());
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const render = (element: ReactNode) => {
|
|
40
|
+
act(() => root.render(element));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const pressEscape = () => {
|
|
44
|
+
act(() => {
|
|
45
|
+
document.body.dispatchEvent(
|
|
46
|
+
new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** What the overlay under test did with the press, and what leaked past it. */
|
|
52
|
+
const assertScoped = (closed: number) => {
|
|
53
|
+
assert.equal(closed, 1, "the overlay did not close itself");
|
|
54
|
+
assert.deepEqual(seen, [], "the layer behind the overlay saw the same press");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("Escape is scoped to the overlay it lands on (#958)", () => {
|
|
58
|
+
it("SlidePanel closes and swallows the key", () => {
|
|
59
|
+
let closed = 0;
|
|
60
|
+
render(
|
|
61
|
+
createElement(
|
|
62
|
+
SlidePanel,
|
|
63
|
+
// createElement never folds the children argument into the props
|
|
64
|
+
// type, so a component with required children needs the cast.
|
|
65
|
+
{
|
|
66
|
+
isOpen: true,
|
|
67
|
+
onClose: () => {
|
|
68
|
+
closed++;
|
|
69
|
+
},
|
|
70
|
+
title: "Add Account",
|
|
71
|
+
} as SlidePanelProps,
|
|
72
|
+
"panel body",
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
pressEscape();
|
|
77
|
+
|
|
78
|
+
assertScoped(closed);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("Dialog closes and swallows the key", () => {
|
|
82
|
+
let closed = 0;
|
|
83
|
+
render(
|
|
84
|
+
createElement(
|
|
85
|
+
Dialog,
|
|
86
|
+
{ open: true, onClose: () => closed++, title: "Folders" },
|
|
87
|
+
"dialog body",
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
pressEscape();
|
|
92
|
+
|
|
93
|
+
assertScoped(closed);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ConfirmDialog closes and swallows the key", () => {
|
|
97
|
+
let closed = 0;
|
|
98
|
+
render(
|
|
99
|
+
createElement(ConfirmDialog, {
|
|
100
|
+
isOpen: true,
|
|
101
|
+
title: "Delete 3 messages?",
|
|
102
|
+
confirmLabel: "Delete",
|
|
103
|
+
onConfirm: () => undefined,
|
|
104
|
+
onCancel: () => closed++,
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
pressEscape();
|
|
109
|
+
|
|
110
|
+
assertScoped(closed);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("PopoverMenu closes and swallows the key", () => {
|
|
114
|
+
render(
|
|
115
|
+
createElement(PopoverMenu, {
|
|
116
|
+
triggerLabel: "More actions",
|
|
117
|
+
items: [
|
|
118
|
+
{ key: "read", label: "Mark as read", onSelect: () => undefined },
|
|
119
|
+
],
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
const trigger = container.querySelector("button") as HTMLButtonElement;
|
|
123
|
+
act(() => trigger.click());
|
|
124
|
+
assert.equal(trigger.getAttribute("aria-expanded"), "true");
|
|
125
|
+
|
|
126
|
+
pressEscape();
|
|
127
|
+
|
|
128
|
+
assert.equal(
|
|
129
|
+
trigger.getAttribute("aria-expanded"),
|
|
130
|
+
"false",
|
|
131
|
+
"the menu did not close itself",
|
|
132
|
+
);
|
|
133
|
+
assert.deepEqual(seen, [], "the layer behind the menu saw the same press");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
import { createPortal } from "react-dom";
|
|
11
11
|
import { cn } from "../lib/cn.js";
|
|
12
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
12
13
|
import { Button } from "./button.js";
|
|
13
14
|
|
|
14
15
|
/** Viewport-relative bounding box of whatever a menu opens against. */
|
|
@@ -293,6 +294,12 @@ export function PopoverMenu({
|
|
|
293
294
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
294
295
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
295
296
|
|
|
297
|
+
useOverlayScope({
|
|
298
|
+
id: "popover-menu",
|
|
299
|
+
open,
|
|
300
|
+
answers: { back: () => setOpen(false) },
|
|
301
|
+
});
|
|
302
|
+
|
|
296
303
|
useEffect(() => {
|
|
297
304
|
if (!open) return;
|
|
298
305
|
const onPointer = (event: MouseEvent) => {
|
|
@@ -304,15 +311,8 @@ export function PopoverMenu({
|
|
|
304
311
|
return;
|
|
305
312
|
setOpen(false);
|
|
306
313
|
};
|
|
307
|
-
const onKey = (event: KeyboardEvent) => {
|
|
308
|
-
if (event.key === "Escape") setOpen(false);
|
|
309
|
-
};
|
|
310
314
|
document.addEventListener("mousedown", onPointer);
|
|
311
|
-
document.
|
|
312
|
-
return () => {
|
|
313
|
-
document.removeEventListener("mousedown", onPointer);
|
|
314
|
-
document.removeEventListener("keydown", onKey);
|
|
315
|
-
};
|
|
315
|
+
return () => document.removeEventListener("mousedown", onPointer);
|
|
316
316
|
}, [open]);
|
|
317
317
|
|
|
318
318
|
if (items.length === 0 && !children) return null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BookPlus, EyeOff } from "lucide-react";
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import { DESKTOP_MEDIA_QUERY } from "../lib/layout-breakpoints.js";
|
|
4
|
+
import { useOverlayScope } from "../lib/overlay-scope.js";
|
|
4
5
|
import { useRovingFocus } from "../lib/roving-focus.js";
|
|
5
6
|
import { useMatchMedia } from "../lib/use-match-media.js";
|
|
6
7
|
import { BottomSheet } from "./bottom-sheet.js";
|
|
@@ -135,33 +136,29 @@ export const RichTextCorrectionMenu = ({
|
|
|
135
136
|
panelRef.current?.focus();
|
|
136
137
|
}, []);
|
|
137
138
|
|
|
139
|
+
// Escape and every other shortcut go through the shared overlay stack, which
|
|
140
|
+
// dismisses the menu and leaves nothing for the layer behind it: the panel's
|
|
141
|
+
// own `onKeyDown` only fires while the panel actually holds focus, and a focus
|
|
142
|
+
// call that lands a beat later than the browser is ready for is not something
|
|
143
|
+
// to depend on. The phone branch rides `BottomSheet`'s scope instead.
|
|
144
|
+
useOverlayScope({
|
|
145
|
+
id: "correction-menu",
|
|
146
|
+
open: desktop,
|
|
147
|
+
answers: { back: () => onDismiss(true) },
|
|
148
|
+
});
|
|
149
|
+
|
|
138
150
|
// A press rather than a click, and a pointer press rather than a mouse one:
|
|
139
151
|
// the tap that opened this menu is followed by the browser's compatibility
|
|
140
152
|
// mouse events, which a `mousedown` listener reads as a press somewhere else
|
|
141
153
|
// and closes the menu on the way up.
|
|
142
|
-
//
|
|
143
|
-
// Escape is caught here too, at the document rather than the panel: the
|
|
144
|
-
// panel takes focus on mount so the panel's own `onKeyDown` ordinarily
|
|
145
|
-
// gets there first and this one never fires (its `stopPropagation` keeps
|
|
146
|
-
// the keydown from reaching here), but a focus call that lands a beat
|
|
147
|
-
// later than the browser is ready for is not something to depend on —
|
|
148
|
-
// this is what actually closes the menu when that happens.
|
|
149
154
|
useEffect(() => {
|
|
150
155
|
if (!desktop) return;
|
|
151
156
|
const onPointer = (event: Event) => {
|
|
152
157
|
if (panelRef.current?.contains(event.target as Node)) return;
|
|
153
158
|
onDismiss(false);
|
|
154
159
|
};
|
|
155
|
-
const onDocumentKeyDown = (event: KeyboardEvent) => {
|
|
156
|
-
if (event.key !== "Escape") return;
|
|
157
|
-
onDismiss(true);
|
|
158
|
-
};
|
|
159
160
|
document.addEventListener("pointerdown", onPointer);
|
|
160
|
-
document.
|
|
161
|
-
return () => {
|
|
162
|
-
document.removeEventListener("pointerdown", onPointer);
|
|
163
|
-
document.removeEventListener("keydown", onDocumentKeyDown);
|
|
164
|
-
};
|
|
161
|
+
return () => document.removeEventListener("pointerdown", onPointer);
|
|
165
162
|
}, [desktop, onDismiss]);
|
|
166
163
|
|
|
167
164
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Search, X } from "lucide-react";
|
|
2
2
|
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
3
3
|
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { resolveAgainstOverlays } from "../lib/overlay-scope.js";
|
|
4
5
|
import type { ComboboxProps } from "../lib/use-suggest-list.js";
|
|
5
6
|
import {
|
|
6
7
|
type ChipFocusTarget,
|
|
@@ -323,6 +324,10 @@ export const SearchChipInput = ({
|
|
|
323
324
|
return;
|
|
324
325
|
}
|
|
325
326
|
if (isEditableTarget(event.target)) return;
|
|
327
|
+
// The field is behind whatever is on top of it: an open overlay either
|
|
328
|
+
// answers the key or contains it, and focusing a field under a modal is
|
|
329
|
+
// neither (#959).
|
|
330
|
+
if (resolveAgainstOverlays("focusSearch")) return;
|
|
326
331
|
event.preventDefault();
|
|
327
332
|
inputRef.current?.focus();
|
|
328
333
|
};
|
|
@@ -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
|
@@ -908,6 +908,13 @@ export {
|
|
|
908
908
|
DESKTOP_MEDIA_QUERY,
|
|
909
909
|
DESKTOP_MIN_WIDTH,
|
|
910
910
|
} from "./lib/layout-breakpoints.js";
|
|
911
|
+
export {
|
|
912
|
+
type OverlayAnswers,
|
|
913
|
+
type OverlayScopeOptions,
|
|
914
|
+
overlayStack,
|
|
915
|
+
resolveAgainstOverlays,
|
|
916
|
+
useOverlayScope,
|
|
917
|
+
} from "./lib/overlay-scope.js";
|
|
911
918
|
export {
|
|
912
919
|
derivePropertyClauses,
|
|
913
920
|
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
|
|