@remit/ui 0.0.153 → 0.0.155
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/bottom-sheet.tsx +3 -0
- package/src/components/confirm-dialog.tsx +7 -20
- package/src/components/dialog.tsx +3 -22
- package/src/components/folder-role.test.ts +59 -7
- package/src/components/folder-role.tsx +22 -19
- package/src/components/mobile-search-view.stories.tsx +1 -1
- package/src/components/overlay-escape.test.ts +135 -0
- package/src/components/popover-menu.tsx +8 -8
- package/src/components/quarantine-entry-row.tsx +3 -2
- package/src/components/quarantine-fixtures.ts +6 -1
- package/src/components/quarantine-report.ts +6 -0
- package/src/components/quarantine-section.render.test.ts +13 -0
- package/src/components/rich-text-correction-menu.tsx +13 -16
- package/src/components/search-chip-input.tsx +5 -0
- package/src/components/search-results.render.test.ts +8 -2
- package/src/components/search-results.stories.tsx +5 -2
- package/src/components/self-update-progress-overlay.tsx +4 -0
- package/src/components/slide-panel.tsx +9 -10
- package/src/index.ts +7 -1
- package/src/lib/overlay-scope.test.ts +314 -0
- package/src/lib/overlay-scope.ts +205 -0
- package/src/lib/shortcut-tree.ts +23 -7
- package/src/lib/use-triage-keyboard.ts +21 -0
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;
|
|
@@ -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({
|
|
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({
|
|
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({
|
|
69
|
+
provenanceFolderLabel({
|
|
70
|
+
providerPath: "[Gmail]/All Mail",
|
|
71
|
+
hierarchyDelimiter: "/",
|
|
72
|
+
}),
|
|
33
73
|
undefined,
|
|
34
74
|
);
|
|
35
75
|
assert.equal(
|
|
36
|
-
provenanceFolderLabel({
|
|
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({
|
|
86
|
+
provenanceFolderLabel({
|
|
87
|
+
providerPath: "[Google Mail]/All Mail",
|
|
88
|
+
hierarchyDelimiter: "/",
|
|
89
|
+
}),
|
|
44
90
|
undefined,
|
|
45
91
|
);
|
|
46
92
|
assert.equal(
|
|
47
|
-
provenanceFolderLabel({
|
|
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(
|
|
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`);
|
|
68
|
-
*
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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.
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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.
|
|
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: {
|
|
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: {
|
|
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: {
|
|
358
|
+
folder: {
|
|
359
|
+
providerPath: "[Gmail]/Important",
|
|
360
|
+
hierarchyDelimiter: "/",
|
|
361
|
+
},
|
|
359
362
|
},
|
|
360
363
|
...crossFolderMatches.slice(0, 1),
|
|
361
364
|
],
|