@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.153",
3
+ "version": "0.0.155",
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;
@@ -10,18 +10,55 @@ describe("provenanceFolderLabel", () => {
10
10
 
11
11
  it("reads a junk appointment as Spam whatever the server calls it", () => {
12
12
  assert.equal(
13
- provenanceFolderLabel({ role: "junk", providerPath: "Bulk Mail" }),
13
+ provenanceFolderLabel({
14
+ role: "junk",
15
+ providerPath: "Bulk Mail",
16
+ hierarchyDelimiter: "/",
17
+ }),
14
18
  "Spam",
15
19
  );
16
20
  });
17
21
 
18
22
  it("falls back to the leaf of a folder nobody appointed", () => {
19
23
  assert.equal(
20
- provenanceFolderLabel({ providerPath: "Projects/Bookkeeping" }),
24
+ provenanceFolderLabel({
25
+ providerPath: "Projects/Bookkeeping",
26
+ hierarchyDelimiter: "/",
27
+ }),
21
28
  "Bookkeeping",
22
29
  );
23
30
  });
24
31
 
32
+ it("cuts the path on the delimiter the server reports, not on a slash", () => {
33
+ assert.equal(
34
+ provenanceFolderLabel({
35
+ providerPath: "INBOX.Projects.Q3",
36
+ hierarchyDelimiter: ".",
37
+ }),
38
+ "Q3",
39
+ );
40
+ });
41
+
42
+ it("leaves a slash alone in a folder name on a dotted server", () => {
43
+ assert.equal(
44
+ provenanceFolderLabel({
45
+ providerPath: "INBOX.Reading/Writing",
46
+ hierarchyDelimiter: ".",
47
+ }),
48
+ "Reading/Writing",
49
+ );
50
+ });
51
+
52
+ it("treats a flat namespace as one folder name", () => {
53
+ assert.equal(
54
+ provenanceFolderLabel({
55
+ providerPath: "Projects/Q3",
56
+ hierarchyDelimiter: "",
57
+ }),
58
+ "Projects/Q3",
59
+ );
60
+ });
61
+
25
62
  it("refuses to label a view rather than a place", () => {
26
63
  assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
27
64
  assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
@@ -29,28 +66,43 @@ describe("provenanceFolderLabel", () => {
29
66
 
30
67
  it("refuses to label Gmail's own reserved namespace", () => {
31
68
  assert.equal(
32
- provenanceFolderLabel({ providerPath: "[Gmail]/All Mail" }),
69
+ provenanceFolderLabel({
70
+ providerPath: "[Gmail]/All Mail",
71
+ hierarchyDelimiter: "/",
72
+ }),
33
73
  undefined,
34
74
  );
35
75
  assert.equal(
36
- provenanceFolderLabel({ providerPath: "[Gmail]/Starred" }),
76
+ provenanceFolderLabel({
77
+ providerPath: "[Gmail]/Starred",
78
+ hierarchyDelimiter: "/",
79
+ }),
37
80
  undefined,
38
81
  );
39
82
  });
40
83
 
41
84
  it("refuses the googlemail.com spelling of the same namespace", () => {
42
85
  assert.equal(
43
- provenanceFolderLabel({ providerPath: "[Google Mail]/All Mail" }),
86
+ provenanceFolderLabel({
87
+ providerPath: "[Google Mail]/All Mail",
88
+ hierarchyDelimiter: "/",
89
+ }),
44
90
  undefined,
45
91
  );
46
92
  assert.equal(
47
- provenanceFolderLabel({ providerPath: "[Google Mail]/Starred" }),
93
+ provenanceFolderLabel({
94
+ providerPath: "[Google Mail]/Starred",
95
+ hierarchyDelimiter: "/",
96
+ }),
48
97
  undefined,
49
98
  );
50
99
  });
51
100
 
52
101
  it("labels a user folder that merely mentions Gmail", () => {
53
- assert.equal(provenanceFolderLabel({ providerPath: "Gmail" }), "Gmail");
102
+ assert.equal(
103
+ provenanceFolderLabel({ providerPath: "Gmail", hierarchyDelimiter: "/" }),
104
+ "Gmail",
105
+ );
54
106
  });
55
107
 
56
108
  it("has nothing to say about a folder it knows nothing about", () => {
@@ -9,6 +9,7 @@ import {
9
9
  Trash2,
10
10
  } from "lucide-react";
11
11
  import type { ReactNode } from "react";
12
+ import { folderLeaf, folderPathSegments } from "../lib/folder-tree.js";
12
13
 
13
14
  /* ------------------------------------------------------------------ */
14
15
  /* Folder role vocabulary (RFC 032 exclusive-folder-appointment, */
@@ -48,30 +49,28 @@ export function canonicalRoleLabel(role: FolderRole): string {
48
49
  return ROLE_LABEL[role];
49
50
  }
50
51
 
51
- /**
52
- * Leaf segment of a provider path (`INBOX/Spam` → `Spam`). Only for the two
53
- * surfaces whose wire payload carries no `hierarchyDelimiter` — quarantine
54
- * entries and search-result provenance. Everything else: `folderLeaf`.
55
- */
56
- export function providerLeaf(providerPath: string): string {
57
- const parts = providerPath.split("/");
58
- return parts[parts.length - 1] || providerPath;
59
- }
60
-
61
52
  /* ------------------------------------------------------------------ */
62
53
  /* Provenance: where a search result actually lives */
63
54
  /* ------------------------------------------------------------------ */
64
55
 
65
56
  /**
66
57
  * The folder a search result was read from. `role` is the account's IMAP
67
- * special-use appointment (`junk` is `\Junk`); accounts that expose a folder
68
- * nobody appointed carry only a `providerPath`.
58
+ * special-use appointment (`junk` is `\Junk`); a folder nobody appointed a role
59
+ * to carries only its path.
60
+ *
61
+ * The path and the separator its own server cuts it on travel together or not
62
+ * at all: `INBOX.Projects.Q3` is three segments on a `.`-delimited server and a
63
+ * single folder name on a `/`-delimited one, so a path alone cannot be cut.
69
64
  */
70
- export interface ResultFolder {
71
- role?: FolderRole;
72
- /** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
73
- providerPath?: string;
74
- }
65
+ export type ResultFolder =
66
+ | {
67
+ role?: FolderRole;
68
+ /** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
69
+ providerPath: string;
70
+ /** The account's own hierarchy separator; `""` means a flat namespace. */
71
+ hierarchyDelimiter: string;
72
+ }
73
+ | { role?: FolderRole; providerPath?: never; hierarchyDelimiter?: never };
75
74
 
76
75
  /**
77
76
  * Roles that name a view rather than a place a message is filed. A message in
@@ -113,8 +112,12 @@ export function provenanceFolderLabel(
113
112
  return canonicalRoleLabel(folder.role);
114
113
  }
115
114
  if (!folder.providerPath) return undefined;
116
- if (GMAIL_NAMESPACES.has(folder.providerPath.split("/")[0])) return undefined;
117
- return providerLeaf(folder.providerPath);
115
+ const segments = folderPathSegments(
116
+ folder.providerPath,
117
+ folder.hierarchyDelimiter,
118
+ );
119
+ if (GMAIL_NAMESPACES.has(segments[0])) return undefined;
120
+ return folderLeaf(folder.providerPath, folder.hierarchyDelimiter);
118
121
  }
119
122
 
120
123
  export function roleIcon(role: FolderRole): ReactNode {
@@ -123,7 +123,7 @@ const crossFolderMatches: SearchResult[] = [
123
123
  subject: "Invoices for the quarter",
124
124
  snippet: "The quarterly set, filed with the rest of the bookkeeping.",
125
125
  date: "Jan 30",
126
- folder: { providerPath: "Projects/Bookkeeping" },
126
+ folder: { providerPath: "Projects/Bookkeeping", hierarchyDelimiter: "/" },
127
127
  },
128
128
  ];
129
129
 
@@ -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,7 +1,8 @@
1
1
  import { Bug } from "lucide-react";
2
+ import { folderLeaf } from "../lib/folder-tree.js";
2
3
  import { Badge } from "./badge.js";
3
4
  import { Button } from "./button.js";
4
- import { canonicalRoleLabel, providerLeaf } from "./folder-role.js";
5
+ import { canonicalRoleLabel } from "./folder-role.js";
5
6
  import {
6
7
  type QuarantineEntry,
7
8
  quarantineSummary,
@@ -47,7 +48,7 @@ export function QuarantineEntryRow({
47
48
  </Badge>
48
49
  )}
49
50
  <span className="truncate" title={entry.mailboxPath}>
50
- {providerLeaf(entry.mailboxPath)}
51
+ {folderLeaf(entry.mailboxPath, entry.mailboxDelimiter)}
51
52
  </span>
52
53
  <span aria-hidden>·</span>
53
54
  <span>{`uid ${entry.uid}`}</span>
@@ -14,6 +14,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
14
14
  uid: 40217,
15
15
  mailboxRole: "inbox",
16
16
  mailboxPath: "INBOX",
17
+ mailboxDelimiter: "/",
17
18
  failureStage: "BodyParse",
18
19
  failureCode: "UnreadableBody",
19
20
  failureMessage: "multipart boundary was never closed",
@@ -42,6 +43,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
42
43
  uid: 40219,
43
44
  mailboxRole: "archive",
44
45
  mailboxPath: "Archive/2026",
46
+ mailboxDelimiter: "/",
45
47
  failureStage: "BodyParse",
46
48
  failureCode: "UnknownCharset",
47
49
  failureMessage: "declared charset is not a known encoding",
@@ -65,7 +67,9 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
65
67
  // A folder the user never appointed a role to — the ordinary state of a
66
68
  // plain folder, and the reason mailboxRole is optional.
67
69
  mailboxRole: undefined,
68
- mailboxPath: "Clients/Acme Holdings",
70
+ mailboxPath: "INBOX.Clients.Acme Holdings",
71
+ // A `.`-delimited server: the row still reads the leaf, not the path.
72
+ mailboxDelimiter: ".",
69
73
  failureStage: "BodyParse",
70
74
  failureCode: "UnreadableBody",
71
75
  failureMessage: "stream ended before the declared body length",
@@ -91,6 +95,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
91
95
  uid: 40263,
92
96
  mailboxRole: "inbox",
93
97
  mailboxPath: "INBOX",
98
+ mailboxDelimiter: "/",
94
99
  failureStage: "BodyParse",
95
100
  failureCode: "UnreadableBody",
96
101
  failureMessage: "connection closed mid-body",
@@ -78,6 +78,12 @@ export interface QuarantineEntry {
78
78
  mailboxRole?: FolderRole;
79
79
  /** The user's own folder name. Shown on screen, withheld from the report. */
80
80
  mailboxPath: string;
81
+ /**
82
+ * The separator that folder's own server cuts its paths on, so the row can
83
+ * show the leaf. Resolved from the account's mailbox list — the quarantine
84
+ * record itself carries no delimiter. `""` means a flat namespace.
85
+ */
86
+ mailboxDelimiter: string;
81
87
  failureStage: QuarantineFailureStage;
82
88
  failureCode: QuarantineFailureCode;
83
89
  /** Parser error text. Shown on screen, never in the report. */
@@ -44,6 +44,19 @@ describe("QuarantineSection", () => {
44
44
  assert.match(html, /Acme Holdings/);
45
45
  });
46
46
 
47
+ it("shows the folder name of a dotted path, not the whole path", () => {
48
+ const html = render([
49
+ {
50
+ ...base,
51
+ mailboxPath: "INBOX.Projects.Q3",
52
+ mailboxDelimiter: ".",
53
+ mailboxRole: undefined,
54
+ },
55
+ ]);
56
+ assert.match(html, />Q3</);
57
+ assert.doesNotMatch(html, />INBOX\.Projects\.Q3</);
58
+ });
59
+
47
60
  it("offers reporting as the only per-row action", () => {
48
61
  const html = render([base]);
49
62
  assert.match(html, /Cut a bug/);
@@ -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
  };
@@ -235,7 +235,10 @@ describe("SearchResults provenance labels", () => {
235
235
  {
236
236
  ...result,
237
237
  id: "c1",
238
- folder: { providerPath: "Projects/Books" },
238
+ folder: {
239
+ providerPath: "Projects/Books",
240
+ hierarchyDelimiter: "/",
241
+ },
239
242
  },
240
243
  ],
241
244
  },
@@ -274,7 +277,10 @@ describe("SearchResults provenance labels", () => {
274
277
  {
275
278
  ...result,
276
279
  id: "v3",
277
- folder: { providerPath: "[Gmail]/Important" },
280
+ folder: {
281
+ providerPath: "[Gmail]/Important",
282
+ hierarchyDelimiter: "/",
283
+ },
278
284
  },
279
285
  ],
280
286
  },
@@ -112,7 +112,7 @@ const crossFolderMatches: SearchResult[] = [
112
112
  subject: "Invoices for the quarter",
113
113
  snippet: "The quarterly set, filed with the rest of the bookkeeping.",
114
114
  date: "Jan 30",
115
- folder: { providerPath: "Projects/Bookkeeping" },
115
+ folder: { providerPath: "Projects/Bookkeeping", hierarchyDelimiter: "/" },
116
116
  },
117
117
  ];
118
118
 
@@ -355,7 +355,10 @@ export const VirtualFoldersGoUnlabelled: Story = {
355
355
  {
356
356
  ...topMatches[2],
357
357
  id: "v3",
358
- folder: { providerPath: "[Gmail]/Important" },
358
+ folder: {
359
+ providerPath: "[Gmail]/Important",
360
+ hierarchyDelimiter: "/",
361
+ },
359
362
  },
360
363
  ...crossFolderMatches.slice(0, 1),
361
364
  ],