@remit/web-client 0.0.161 → 0.0.163

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.
@@ -1,15 +1,13 @@
1
1
  /**
2
2
  * Reading a message and answering it, on a desktop pane.
3
3
  *
4
- * The inline reply was held below the conversation rather than inside it. It
5
- * took whatever height the message left over, which on a normal-length one is
6
- * nothing: the recipient rows and the verbs stayed, the writing area went down
7
- * to a couple of lines, and scrolling the message could not reach it because
8
- * the thing to reach was already on screen and squeezed.
4
+ * The reply leads the pane and the thread reads newest first under it, so what
5
+ * is being written and the turn it answers are the two things at the top. Both
6
+ * live in the pane's own scrolling region: the reply has no height of its own
7
+ * and no scroller of its own, which is what kept a second scrollbar with the
8
+ * caret in the inner one out of a single column.
9
9
  *
10
- * The reply belongs to the conversation and scrolls with it, and the chevron
11
- * beside the sender is the control that puts a message away — reclaiming the
12
- * space is what a reader reaches for it to do.
10
+ * The chevron beside the sender is the control that puts a message away.
13
11
  */
14
12
 
15
13
  import assert from "node:assert/strict";
@@ -41,6 +39,24 @@ const MESSAGE_ID = "msg-1";
41
39
 
42
40
  const account = makeAccount({ accountId: ACCOUNT_ID });
43
41
 
42
+ const OPENING_SENT = 1_767_139_200_000;
43
+ const LATEST_SENT = 1_767_225_600_000;
44
+
45
+ // The turn that opened the conversation, and the one that answered it. The API
46
+ // hands them over oldest first (#81), which is the order this mock serves them
47
+ // in — the pane is what decides which end of that goes at the top.
48
+ const openingMessage: RemitImapThreadMessageResponse = makeThreadMessage({
49
+ messageId: "msg-0",
50
+ threadId: THREAD_ID,
51
+ mailboxId: MAILBOX_ID,
52
+ accountId: ACCOUNT_ID,
53
+ subject: "Lunch Thursday?",
54
+ fromName: "Grace Hopper",
55
+ fromEmail: "grace@example.com",
56
+ sentDate: OPENING_SENT,
57
+ isRead: true,
58
+ });
59
+
44
60
  const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
45
61
  messageId: MESSAGE_ID,
46
62
  threadId: THREAD_ID,
@@ -49,6 +65,7 @@ const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
49
65
  subject: "Lunch Thursday?",
50
66
  fromName: "Ada Lovelace",
51
67
  fromEmail: "ada@example.com",
68
+ sentDate: LATEST_SENT,
52
69
  // Already read: marking one read on open is a mutation this test has no
53
70
  // business driving.
54
71
  isRead: true,
@@ -141,7 +158,7 @@ const mount = async (): Promise<DomHarness> => {
141
158
  http = mockFetch((call) => {
142
159
  if (call.path.endsWith("/config")) return { accounts: [account] };
143
160
  if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
144
- return { items: [threadMessage] };
161
+ return { items: [openingMessage, threadMessage] };
145
162
  }
146
163
  if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
147
164
  return { items: [] };
@@ -176,6 +193,35 @@ const pressReply = async (mounted: DomHarness): Promise<void> => {
176
193
  const paneRegionHolding = (pane: Element, node: Node): Element | null =>
177
194
  [...pane.children].find((child) => child.contains(node)) ?? null;
178
195
 
196
+ /**
197
+ * The utilities that make an element scroll vertically. A horizontal one — the
198
+ * formatting toolbar's strip of buttons — is a different thing and is not
199
+ * counted: it holds a row that would otherwise be cut off, not a column of
200
+ * content the reader moves through.
201
+ */
202
+ const SCROLLS_VERTICALLY = /(^|\s)overflow-(auto|scroll|y-auto|y-scroll)(\s|$)/;
203
+
204
+ /** The inset ring `MessageCard` draws on the row the keyboard is sitting on. */
205
+ const FOCUS_RING = /ring-accent\//;
206
+
207
+ /** The turn the keyboard has, read off the ring rather than off the index. */
208
+ const focusedTurn = (mounted: DomHarness): string => {
209
+ const thread = mounted.query('[data-testid="conversation-messages"]');
210
+ assert.ok(thread, "the thread is on screen");
211
+ const focused = [...thread.children].filter((card) =>
212
+ [...card.querySelectorAll("*")].some((node) =>
213
+ FOCUS_RING.test(node.getAttribute("class") ?? ""),
214
+ ),
215
+ );
216
+ assert.equal(focused.length, 1, "one turn carries the keyboard focus");
217
+ return focused[0]?.textContent ?? "";
218
+ };
219
+
220
+ const verticalScrollers = (root: Element): Element[] =>
221
+ [root, ...root.querySelectorAll("*")].filter((node) =>
222
+ SCROLLS_VERTICALLY.test(node.getAttribute("class") ?? ""),
223
+ );
224
+
179
225
  describe("answering the message that is open", () => {
180
226
  it("puts the reply in the same scrolling region as the message", async () => {
181
227
  const mounted = await mount();
@@ -199,6 +245,94 @@ describe("answering the message that is open", () => {
199
245
  );
200
246
  });
201
247
 
248
+ it("leads the pane with the reply, above the thread it answers", async () => {
249
+ const mounted = await mount();
250
+
251
+ await pressReply(mounted);
252
+
253
+ const compose = mounted.query('[data-testid="compose-body-area"]');
254
+ assert.ok(compose, "the reply opened");
255
+
256
+ const thread = mounted.query('[data-testid="conversation-messages"]');
257
+ assert.ok(thread, "the thread is on screen");
258
+
259
+ const following = mounted.window.Node.DOCUMENT_POSITION_FOLLOWING;
260
+ assert.ok(
261
+ (compose.compareDocumentPosition(thread) & following) === following,
262
+ "the reply comes before the messages, not after the last of them",
263
+ );
264
+ });
265
+
266
+ it("reads the thread newest first", async () => {
267
+ const mounted = await mount();
268
+
269
+ const thread = mounted.query('[data-testid="conversation-messages"]');
270
+ assert.ok(thread, "the thread is on screen");
271
+
272
+ const cards = [...thread.children].map((card) => card.textContent ?? "");
273
+ assert.equal(cards.length, 2, "both turns are on screen");
274
+ assert.ok(
275
+ cards[0]?.includes("Ada Lovelace"),
276
+ "the turn that answered the conversation is at the top",
277
+ );
278
+ assert.ok(
279
+ cards[1]?.includes("Grace Hopper"),
280
+ "the turn that opened it is below",
281
+ );
282
+ });
283
+
284
+ it("walks the thread with j and k in the order it is displayed in", async () => {
285
+ const mounted = await mount();
286
+
287
+ assert.ok(
288
+ focusedTurn(mounted).includes("Ada Lovelace"),
289
+ "the keyboard starts on the turn at the top, which is the latest one",
290
+ );
291
+
292
+ const press = async (key: string): Promise<void> => {
293
+ mounted.dispatch(
294
+ mounted.window,
295
+ new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
296
+ );
297
+ await mounted.flush();
298
+ };
299
+
300
+ // Down the pane is back in time now that the thread reads newest first.
301
+ // A reorder that left the handlers alone would invert this silently, and
302
+ // the help overlay describes the same direction this asserts.
303
+ await press("j");
304
+ assert.ok(
305
+ focusedTurn(mounted).includes("Grace Hopper"),
306
+ "j moves to the turn below, which is the older one",
307
+ );
308
+
309
+ await press("k");
310
+ assert.ok(
311
+ focusedTurn(mounted).includes("Ada Lovelace"),
312
+ "k moves to the turn above, which is the newer one",
313
+ );
314
+ });
315
+
316
+ it("gives the pane one scrollbar, reply open or not", async () => {
317
+ const mounted = await mount();
318
+
319
+ const pane = mounted.query("article");
320
+ assert.ok(pane, "the conversation pane is mounted");
321
+ assert.equal(
322
+ verticalScrollers(pane).length,
323
+ 1,
324
+ "reading a thread scrolls one thing",
325
+ );
326
+
327
+ await pressReply(mounted);
328
+
329
+ assert.equal(
330
+ verticalScrollers(pane).length,
331
+ 1,
332
+ "the reply is a block of the pane, not a box with a scrollbar of its own next to the editor",
333
+ );
334
+ });
335
+
202
336
  it("collapses the message from the chevron beside the sender", async () => {
203
337
  const mounted = await mount();
204
338
 
@@ -215,9 +349,10 @@ describe("answering the message that is open", () => {
215
349
  mounted.query('[aria-label="Collapse message"]') === null,
216
350
  "the chevron collapsed the message it belongs to",
217
351
  );
218
- assert.ok(
219
- mounted.query('[role="button"][aria-expanded="false"]'),
220
- "the message is back to a collapsed row",
352
+ assert.equal(
353
+ mounted.queryAll('[role="button"][aria-expanded="false"]').length,
354
+ 2,
355
+ "the message is back to a collapsed row, beside the one that was already collapsed",
221
356
  );
222
357
  });
223
358
  });
@@ -22,6 +22,8 @@ export const KeyboardShortcutsModal = ({
22
22
  (event: KeyboardEvent) => {
23
23
  if (event.key === "Escape") {
24
24
  event.preventDefault();
25
+ event.stopPropagation();
26
+ event.stopImmediatePropagation();
25
27
  onClose();
26
28
  }
27
29
  },
@@ -30,8 +32,11 @@ export const KeyboardShortcutsModal = ({
30
32
 
31
33
  useEffect(() => {
32
34
  if (!isOpen) return;
33
- window.addEventListener("keydown", handleKeyDown);
34
- return () => window.removeEventListener("keydown", handleKeyDown);
35
+ // Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
36
+ // so Escape dismisses it and nothing else. Shared with the list's own
37
+ // Escape, the one keystroke also closed the conversation underneath.
38
+ window.addEventListener("keydown", handleKeyDown, true);
39
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
35
40
  }, [isOpen, handleKeyDown]);
36
41
 
37
42
  if (!isOpen) return null;
@@ -65,7 +65,6 @@ const mailContext = (
65
65
  onSearchClearQuery: () => {},
66
66
  intelligenceOpen: false,
67
67
  onToggleIntelligence: () => {},
68
- onSetIntelligenceOpen: () => {},
69
68
  });
70
69
 
71
70
  type ListPath = "/mail/brief" | "/mail/flagged";
@@ -71,16 +71,24 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
71
71
  ...prev,
72
72
  q: committedQuery || undefined,
73
73
  });
74
+ // A query is a mode of the view the reader is already in, so the panels
75
+ // they have up are not ones they navigated away from.
74
76
  if (!queryGoesActive) {
75
- navigate({ to: ".", search, replace: true });
77
+ navigate({ to: ".", search, hash: true, replace: true });
76
78
  return;
77
79
  }
78
80
  if (to === "/mail/$mailboxId") {
79
81
  if (!mailboxId) return;
80
- navigate({ to, params: { mailboxId }, search, replace: true });
82
+ navigate({
83
+ to,
84
+ params: { mailboxId },
85
+ search,
86
+ hash: true,
87
+ replace: true,
88
+ });
81
89
  return;
82
90
  }
83
- navigate({ to, search, replace: true });
91
+ navigate({ to, search, hash: true, replace: true });
84
92
  }, [
85
93
  searchInput,
86
94
  committedQuery,
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
2
2
  import { afterEach, beforeEach, describe, it } from "node:test";
3
3
  import {
4
4
  readIntelligencePref,
5
+ resolveRailOpen,
5
6
  writeIntelligencePref,
6
7
  } from "./intelligence-pref.js";
7
8
 
@@ -55,3 +56,79 @@ describe("intelligence-pref (#782)", () => {
55
56
  assert.doesNotThrow(() => writeIntelligencePref(false));
56
57
  });
57
58
  });
59
+
60
+ describe("resolveRailOpen (#722)", () => {
61
+ const withThread = { hasThread: true, isDesktop: true };
62
+
63
+ it("opens the rail with the thread where the address says nothing", () => {
64
+ assert.equal(
65
+ resolveRailOpen({ ...withThread, panels: [], prefersOpen: true }),
66
+ true,
67
+ );
68
+ });
69
+
70
+ it("leaves the rail down where the address says nothing and the reader collapsed it", () => {
71
+ assert.equal(
72
+ resolveRailOpen({ ...withThread, panels: [], prefersOpen: false }),
73
+ false,
74
+ );
75
+ });
76
+
77
+ it("never seeds the rail below the tier that has one", () => {
78
+ assert.equal(
79
+ resolveRailOpen({
80
+ panels: [],
81
+ prefersOpen: true,
82
+ isDesktop: false,
83
+ hasThread: true,
84
+ }),
85
+ false,
86
+ );
87
+ });
88
+
89
+ // Otherwise the address claims a pane the shell has nothing to put in it.
90
+ it("never seeds the rail with no conversation open", () => {
91
+ assert.equal(
92
+ resolveRailOpen({
93
+ panels: [],
94
+ prefersOpen: true,
95
+ isDesktop: true,
96
+ hasThread: false,
97
+ }),
98
+ false,
99
+ );
100
+ });
101
+
102
+ // A shared link naming another panel is an address that has spoken: the
103
+ // recipient's own preference does not get to add the rail to it.
104
+ it("hands a shared link's panels to the reader who opened it", () => {
105
+ assert.equal(
106
+ resolveRailOpen({
107
+ ...withThread,
108
+ panels: ["shortcuts"],
109
+ prefersOpen: true,
110
+ }),
111
+ false,
112
+ );
113
+ assert.equal(
114
+ resolveRailOpen({
115
+ ...withThread,
116
+ panels: ["intelligence", "shortcuts"],
117
+ prefersOpen: false,
118
+ }),
119
+ true,
120
+ );
121
+ });
122
+
123
+ it("opens the rail from an address on any tier", () => {
124
+ assert.equal(
125
+ resolveRailOpen({
126
+ panels: ["intelligence"],
127
+ prefersOpen: false,
128
+ isDesktop: false,
129
+ hasThread: true,
130
+ }),
131
+ true,
132
+ );
133
+ });
134
+ });
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Persistence for the intelligence-pane open/closed preference (#782).
2
+ * Persistence for the intelligence-pane open/closed preference (#782), and the
3
+ * precedence between it and the address (#722).
3
4
  *
4
5
  * The pane opens with the thread by default; a manual collapse sticks across
5
6
  * sessions. Storage failures (private mode / quota) fall back to the default
6
7
  * (open) rather than crashing.
7
8
  */
9
+ import type { PanelFragment } from "@/routing";
8
10
 
9
11
  export const INTELLIGENCE_PREF_KEY = "remit:intelligence-open";
10
12
 
@@ -23,3 +25,33 @@ export function writeIntelligencePref(open: boolean): void {
23
25
  // Storage unavailable — the in-memory default stands.
24
26
  }
25
27
  }
28
+
29
+ export interface RailVisibility {
30
+ /** The panels the address names. */
31
+ panels: readonly PanelFragment[];
32
+ prefersOpen: boolean;
33
+ /** The rail is a pane of the desktop shell; narrower tiers have a drawer. */
34
+ isDesktop: boolean;
35
+ /** The rail reads a conversation, so with none open there is nothing to be up. */
36
+ hasThread: boolean;
37
+ }
38
+
39
+ /**
40
+ * Whether the rail is up.
41
+ *
42
+ * An address that names any panel is the only owner of what is open, so a
43
+ * shared link showing the shortcuts sheet is not overwritten by the recipient's
44
+ * own preference. The preference speaks only where the address is silent, and
45
+ * only with a conversation open on the tier that has a rail — it opens with the
46
+ * thread there (#782), while a phone would get a full-screen drawer over a
47
+ * message nobody asked to cover.
48
+ */
49
+ export function resolveRailOpen({
50
+ panels,
51
+ prefersOpen,
52
+ isDesktop,
53
+ hasThread,
54
+ }: RailVisibility): boolean {
55
+ if (panels.length > 0) return panels.includes("intelligence");
56
+ return isDesktop && hasThread && prefersOpen;
57
+ }
@@ -47,13 +47,14 @@ export interface MailContextValue {
47
47
  onSearchClear: () => void;
48
48
  /** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
49
49
  onSearchClearQuery: () => void;
50
- /** Pane 4 (intelligence) visibility. The shared toggle starts closed; the
51
- * desktop route opens it by default with the thread, honouring the stored
52
- * preference (#782). */
50
+ /**
51
+ * Whether pane 4 (intelligence) is up. Resolved once by the `/mail` layout
52
+ * from the address and this device's preference (`resolveRailOpen`), and
53
+ * handed down as the answer — a consumer re-deriving it from the fragment
54
+ * alone would lose the preference and disagree with the shell.
55
+ */
53
56
  intelligenceOpen: boolean;
54
57
  onToggleIntelligence: () => void;
55
- /** Set the pane open/closed and persist the choice (desktop default-open). */
56
- onSetIntelligenceOpen: (open: boolean) => void;
57
58
  }
58
59
 
59
60
  export const MailContext = createContext<MailContextValue | null>(null);
@@ -77,7 +78,6 @@ export const useMailContext = (): MailContextValue => {
77
78
  onSearchClearQuery: () => {},
78
79
  intelligenceOpen: false,
79
80
  onToggleIntelligence: () => {},
80
- onSetIntelligenceOpen: () => {},
81
81
  }
82
82
  );
83
83
  };
@@ -27,13 +27,24 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
27
27
  import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
28
28
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
29
29
  import { hostsComposeSurface } from "@/lib/compose-routes";
30
- import { writeIntelligencePref } from "@/lib/intelligence-pref";
30
+ import {
31
+ readIntelligencePref,
32
+ resolveRailOpen,
33
+ writeIntelligencePref,
34
+ } from "@/lib/intelligence-pref";
31
35
  import { MailContext } from "@/lib/mail-context";
32
36
  import { MailFreshnessProvider } from "@/lib/mail-freshness";
33
37
  import { mailViewKey } from "@/lib/mail-route";
34
38
  import { buildAccountNameIndex } from "@/lib/search-token-index";
35
39
  import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
36
40
  import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
41
+ import {
42
+ isOverlayPanel,
43
+ type OverlayPanel,
44
+ useOpenPanels,
45
+ useOpenThreadPath,
46
+ useSetOpenPanels,
47
+ } from "@/routing";
37
48
  import "@/lib/client";
38
49
 
39
50
  // `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
@@ -79,19 +90,56 @@ function MailLayout() {
79
90
  // tablet with no compose surface (compose lives in the reading pane, which
80
91
  // tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
81
92
  const isSinglePane = isSinglePaneTier(tier);
82
- const [showShortcuts, setShowShortcuts] = useState(false);
83
- const [drawerOpen, setDrawerOpen] = useState(false);
84
- // Pane 4 / the mobile details drawer share this toggle. It starts closed so
85
- // the phone never slams a full-screen intelligence drawer over a freshly
86
- // opened thread; the DESKTOP route opens it by default with the thread (the
87
- // intelligence rail is the product's core value) and honours the persisted
88
- // collapse preference there (#782). DKIM-mismatch auto-open still fires on
89
- // every tier. Explicit toggles persist the user's choice.
90
- const [intelligenceOpen, setIntelligenceOpen] = useState(false);
91
- const handleSetIntelligenceOpen = useCallback((open: boolean) => {
92
- setIntelligenceOpen(open);
93
- writeIntelligencePref(open);
94
- }, []);
93
+ // The panels the address carries (#722): the intelligence rail, the nav
94
+ // slide-over and the shortcuts sheet. The rail is a pane and the other two
95
+ // cover it, so the address holds a pane and an overlay at once — a sheet
96
+ // opening never takes the rail down while two overlays cannot both be up.
97
+ const openPanels = useOpenPanels();
98
+ const setOpenPanels = useSetOpenPanels();
99
+ const openOverlay = openPanels.find(isOverlayPanel);
100
+ const showShortcuts = openOverlay === "shortcuts";
101
+ const drawerOpen = openOverlay === "nav";
102
+ // Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
103
+ // one place the address and the stored preference meet: the address decides
104
+ // whenever it says anything at all, and the preference opens the rail with
105
+ // the thread where it is silent (#782).
106
+ const openThread = useOpenThreadPath();
107
+ // Held in state, not read back from storage each render: closing the rail
108
+ // where the address is silent changes nothing about the address, and the
109
+ // answer has to move anyway.
110
+ const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
111
+ const intelligenceOpen = resolveRailOpen({
112
+ panels: openPanels,
113
+ prefersOpen: prefersRail,
114
+ isDesktop: tier === "desktop",
115
+ hasThread: openThread !== undefined,
116
+ });
117
+ // Every write states the whole set, because it is composed from what is
118
+ // showing rather than from what the address happens to spell: the rail open
119
+ // by preference alone is still open, and an overlay must not close it.
120
+ const showPanels = useCallback(
121
+ (rail: boolean, overlay: OverlayPanel | undefined) => {
122
+ setOpenPanels([
123
+ ...(rail ? (["intelligence"] as const) : []),
124
+ ...(overlay ? [overlay] : []),
125
+ ]);
126
+ },
127
+ [setOpenPanels],
128
+ );
129
+ const handleSetIntelligenceOpen = useCallback(
130
+ (open: boolean) => {
131
+ writeIntelligencePref(open);
132
+ setPrefersRail(open);
133
+ showPanels(open, openOverlay);
134
+ },
135
+ [openOverlay, showPanels],
136
+ );
137
+ const showOverlay = useCallback(
138
+ (overlay: OverlayPanel | undefined) => {
139
+ showPanels(intelligenceOpen, overlay);
140
+ },
141
+ [intelligenceOpen, showPanels],
142
+ );
95
143
 
96
144
  // Within one view, URL `q` seeds the input and is a one-directional write
97
145
  // target: the debounced local value drives the search API and is mirrored
@@ -157,7 +205,7 @@ function MailLayout() {
157
205
  bindings: [
158
206
  {
159
207
  key: "?",
160
- handler: () => setShowShortcuts(true),
208
+ handler: () => showOverlay("shortcuts"),
161
209
  noModifiers: false, // Allow shift+/
162
210
  preventDefault: true,
163
211
  },
@@ -193,12 +241,8 @@ function MailLayout() {
193
241
  }, []);
194
242
 
195
243
  const handleToggleIntelligence = useCallback(() => {
196
- setIntelligenceOpen((open) => {
197
- const next = !open;
198
- writeIntelligencePref(next);
199
- return next;
200
- });
201
- }, []);
244
+ handleSetIntelligenceOpen(!intelligenceOpen);
245
+ }, [handleSetIntelligenceOpen, intelligenceOpen]);
202
246
 
203
247
  const accounts = config?.accounts ?? [];
204
248
  const accountIds = useMemo(
@@ -218,11 +262,12 @@ function MailLayout() {
218
262
  useStaleAccountSync(accounts);
219
263
 
220
264
  const handleMailboxSelect = useCallback(() => {
221
- // Auto-collapse the mobile drawer after the user picks an inbox
222
- // from the sidebar (#199). Desktop sidebar isn't a drawer so the
223
- // noop is fine there.
224
- setDrawerOpen(false);
225
- }, []);
265
+ // Auto-collapse the mobile drawer after the user picks an inbox from the
266
+ // sidebar (#199). Above the slide-over the sidebar is a pane, and there is
267
+ // no overlay of its own to take down.
268
+ if (openOverlay !== "nav") return;
269
+ showOverlay(undefined);
270
+ }, [openOverlay, showOverlay]);
226
271
 
227
272
  const mailContextValue = {
228
273
  accounts,
@@ -239,7 +284,6 @@ function MailLayout() {
239
284
  onSearchClearQuery: handleSearchClearQuery,
240
285
  intelligenceOpen,
241
286
  onToggleIntelligence: handleToggleIntelligence,
242
- onSetIntelligenceOpen: handleSetIntelligenceOpen,
243
287
  };
244
288
 
245
289
  // Single nav node: the kit renders it as a pane (≥1024px) or inside its
@@ -259,8 +303,8 @@ function MailLayout() {
259
303
  // top bar owns compose.
260
304
  overlay: isSinglePane ? <ComposeFab /> : undefined,
261
305
  navOpen: drawerOpen,
262
- onOpenNav: () => setDrawerOpen(true),
263
- onCloseNav: () => setDrawerOpen(false),
306
+ onOpenNav: () => showOverlay("nav"),
307
+ onCloseNav: () => showOverlay(undefined),
264
308
  };
265
309
 
266
310
  return (
@@ -284,7 +328,7 @@ function MailLayout() {
284
328
  )}
285
329
  <KeyboardShortcutsModal
286
330
  isOpen={showShortcuts}
287
- onClose={() => setShowShortcuts(false)}
331
+ onClose={() => showOverlay(undefined)}
288
332
  />
289
333
  </MailFreshnessProvider>
290
334
  </MailContext.Provider>
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ formatOpenPanels,
5
+ panelFragments,
6
+ parseOpenPanels,
7
+ retainOpenPanels,
8
+ } from "./fragment.js";
9
+
10
+ describe("parseOpenPanels", () => {
11
+ it("reads every panel the union names", () => {
12
+ for (const panel of panelFragments) {
13
+ assert.deepEqual(parseOpenPanels(panel), [panel]);
14
+ }
15
+ });
16
+
17
+ it("reads a pane and an overlay together", () => {
18
+ assert.deepEqual(parseOpenPanels("intelligence,shortcuts"), [
19
+ "intelligence",
20
+ "shortcuts",
21
+ ]);
22
+ });
23
+
24
+ it("spells one set of panels one way", () => {
25
+ assert.deepEqual(parseOpenPanels("shortcuts,intelligence,shortcuts"), [
26
+ "intelligence",
27
+ "shortcuts",
28
+ ]);
29
+ });
30
+
31
+ it("reads an empty, unknown or mis-cased fragment as no panel", () => {
32
+ assert.deepEqual(parseOpenPanels(""), []);
33
+ assert.deepEqual(parseOpenPanels("confirm-delete"), []);
34
+ assert.deepEqual(parseOpenPanels("Intelligence"), []);
35
+ assert.deepEqual(parseOpenPanels("form:input:to"), []);
36
+ });
37
+
38
+ it("keeps the panels it recognises out of a fragment carrying junk", () => {
39
+ assert.deepEqual(parseOpenPanels("filters,intelligence"), ["intelligence"]);
40
+ });
41
+ });
42
+
43
+ describe("formatOpenPanels", () => {
44
+ it("writes the address a set of panels is read back from", () => {
45
+ assert.equal(
46
+ formatOpenPanels(["shortcuts", "intelligence"]),
47
+ "intelligence,shortcuts",
48
+ );
49
+ assert.equal(formatOpenPanels([]), "");
50
+ });
51
+ });
52
+
53
+ describe("retainOpenPanels", () => {
54
+ it("carries a pane across a navigation", () => {
55
+ assert.equal(retainOpenPanels("intelligence"), "intelligence");
56
+ });
57
+
58
+ it("leaves the overlays behind, and keeps the pane under them", () => {
59
+ assert.equal(retainOpenPanels("nav"), "");
60
+ assert.equal(retainOpenPanels("shortcuts"), "");
61
+ assert.equal(retainOpenPanels("intelligence,shortcuts"), "intelligence");
62
+ });
63
+
64
+ it("drops a fragment it does not recognise rather than passing it on", () => {
65
+ assert.equal(retainOpenPanels(), "");
66
+ assert.equal(retainOpenPanels("confirm-delete"), "");
67
+ });
68
+ });