@remit/ui 0.0.152 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.152",
3
+ "version": "0.0.154",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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 { useCallback, useEffect, useRef } from "react";
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
- const handleKeyDown = useCallback(
42
- (event: KeyboardEvent) => {
43
- if (event.key === "Escape") {
44
- event.preventDefault();
45
- event.stopPropagation();
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, useCallback, useEffect, useRef } from "react";
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
- const handleKeyDown = useCallback(
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;
@@ -157,7 +157,7 @@ export function clauseFieldHint(field: ClauseField): string | undefined {
157
157
  *
158
158
  * The vector-free matcher serves `From`/`Subject` from the core thread rows and
159
159
  * carries no faithful body, so it rejects a body-text clause outright rather
160
- * than narrowing the match silently (`assertNoBodyContentClause`,
160
+ * than narrowing the match silently (`bodyContentRejection`,
161
161
  * backend/service/organize.ts). A rule with no active widen therefore cannot be
162
162
  * counted or applied one-time with such a clause in it — it can only be a
163
163
  * standing rule, where the index-time matcher reads the whole body.
@@ -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.addEventListener("keydown", onKey);
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.addEventListener("keydown", onDocumentKeyDown);
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 { type ReactNode, useEffect } from "react";
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 pointer affordance only.
32
- useEffect(() => {
33
- if (!isOpen) return;
34
- const onKeyDown = (event: KeyboardEvent) => {
35
- if (event.key === "Escape") onClose();
36
- };
37
- document.addEventListener("keydown", onKeyDown);
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
+ }
@@ -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 frame = tree.overlays.at(-1);
338
- if (frame) {
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