@remit/web-client 0.0.51 → 0.0.53

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/web-client",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * The phone brief must mount the same selection surface the multi-pane list
9
+ * does (#203). `BriefPane` wires the bulk verbs and the selection bar into
10
+ * `DailyBrief` from the `BriefList` slot; the phone view used to render
11
+ * `DailyBrief` directly, without `onDeleteMessages`, so a selection made in the
12
+ * brief on a phone raised no action bar — selectable, but nothing to act with.
13
+ *
14
+ * As with `MessageList.selection.test.ts`, `BriefPane` weaves routing and
15
+ * several data hooks together, so the rule is enforced by reading the source:
16
+ * the phone view reuses the list slot rather than a bare `DailyBrief`.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const source = readFileSync(resolve(here, "BriefPane.tsx"), "utf8");
21
+
22
+ const briefPhoneBody = (): string => {
23
+ const start = source.indexOf("function BriefPhone(");
24
+ assert.notEqual(start, -1, "BriefPhone is defined");
25
+ const next = source.indexOf("\nfunction ", start + 1);
26
+ return source.slice(start, next === -1 ? undefined : next);
27
+ };
28
+
29
+ describe("BriefPane phone selection surface (#203)", () => {
30
+ it("the phone brief reuses the list slot, not a bare DailyBrief", () => {
31
+ const body = briefPhoneBody();
32
+ assert.match(
33
+ body,
34
+ /<BriefList \/>/,
35
+ "phone brief renders the list slot that wires the bulk verbs",
36
+ );
37
+ assert.doesNotMatch(
38
+ body,
39
+ /<DailyBrief\b/,
40
+ "phone brief must not render DailyBrief directly — that drops onDeleteMessages and the selection bar with it",
41
+ );
42
+ });
43
+
44
+ it("the list slot hands DailyBrief the delete verb the selection bar gates on", () => {
45
+ const start = source.indexOf("function BriefList(");
46
+ assert.notEqual(start, -1, "BriefList is defined");
47
+ const next = source.indexOf("\nfunction ", start + 1);
48
+ const body = source.slice(start, next === -1 ? undefined : next);
49
+ assert.match(body, /onDeleteMessages=\{onDeleteMessages\}/);
50
+ assert.match(body, /onMarkMessagesRead=\{onMarkMessagesRead\}/);
51
+ });
52
+ });
@@ -381,14 +381,12 @@ function BriefPhone() {
381
381
  const {
382
382
  selectedThread,
383
383
  conversation,
384
- selectedMessageId,
385
384
  onSelectMessage,
386
- onSelectSearchResult,
387
385
  onCloseThread,
388
386
  nextMessageId,
389
387
  previousMessageId,
390
388
  } = useBriefPane();
391
- const { accounts, intelligenceOpen, onToggleIntelligence } = useMailContext();
389
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
392
390
 
393
391
  if (conversation) {
394
392
  return (
@@ -427,14 +425,14 @@ function BriefPhone() {
427
425
  );
428
426
  }
429
427
 
428
+ // The same list slot the multi-pane layout mounts, so the phone brief wires
429
+ // the bulk verbs and the selection bar through `DailyBrief` exactly as the
430
+ // desktop list does. Rendering `DailyBrief` here directly dropped
431
+ // `onDeleteMessages`, so a phone selection raised no action bar (#203) — the
432
+ // same reuse `FlaggedPhone` already relies on.
430
433
  return (
431
434
  <div className="h-full">
432
- <DailyBrief
433
- accounts={accounts}
434
- selectedMessageId={selectedMessageId}
435
- onSelectMessage={onSelectMessage}
436
- onSelectSearchResult={onSelectSearchResult}
437
- />
435
+ <BriefList />
438
436
  </div>
439
437
  );
440
438
  }
@@ -212,7 +212,7 @@ const ExpandedCard = ({
212
212
  return (
213
213
  <ExpandedMessage
214
214
  message={message}
215
- isFocused={isFocused}
215
+ isFocused={mobile ? false : isFocused}
216
216
  onHeaderClick={onToggle}
217
217
  senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
218
218
  trailing={
@@ -91,3 +91,27 @@ describe("MessageList escalated actions", () => {
91
91
  );
92
92
  });
93
93
  });
94
+
95
+ /**
96
+ * A bounded confirm-delete used to open the surviving neighbour by writing
97
+ * `selectedMessageId` into the URL. On desktop that fills the reading pane
98
+ * beside the list; on a single-pane mobile layout the same navigation replaced
99
+ * the list with a full-screen message, so a bulk delete read as "jumped into a
100
+ * random message" rather than "the rows are gone" (#202). Mobile now stays on
101
+ * the list and raises the same completion banner a chunked run does.
102
+ */
103
+ describe("MessageList bounded delete stays on the list on mobile (#202)", () => {
104
+ it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
105
+ assert.match(
106
+ source,
107
+ /if \(isDesktop\) \{\s*navigate\(\{\s*to: "\/mail\/\$mailboxId",\s*params: \{ mailboxId \},\s*search: \(prev\) => \(\{ \.\.\.prev, selectedMessageId: nextFocus \}\),\s*replace: true,\s*\}\);\s*\}/,
108
+ );
109
+ });
110
+
111
+ it("raises a completion banner on mobile so the delete is not silent", () => {
112
+ assert.match(
113
+ source,
114
+ /if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length\),?\s*\);\s*\}/,
115
+ );
116
+ });
117
+ });
@@ -662,12 +662,26 @@ export const MessageList = ({
662
662
  pendingDomFocusRef.current = nextFocus;
663
663
  cursorMovedByPointerRef.current = false;
664
664
  setFocusedMessageId(nextFocus);
665
- navigate({
666
- to: "/mail/$mailboxId",
667
- params: { mailboxId },
668
- search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
669
- replace: true,
670
- });
665
+ // Desktop is two-pane: opening the neighbour fills the reading pane
666
+ // beside the list. On a single-pane mobile layout the same navigation
667
+ // replaces the list with a full-screen message, so a bulk delete looks
668
+ // like it opened a random neighbour instead of removing the rows (#202).
669
+ // Mobile keeps the cursor move but stays on the list.
670
+ if (isDesktop) {
671
+ navigate({
672
+ to: "/mail/$mailboxId",
673
+ params: { mailboxId },
674
+ search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
675
+ replace: true,
676
+ });
677
+ }
678
+ }
679
+
680
+ // Mobile keeps the list up, so it needs its own signal the delete landed —
681
+ // the transient completion banner a chunked run already raises (#202). On
682
+ // desktop the rows leaving the list beside the reading pane is signal enough.
683
+ if (!isDesktop) {
684
+ setCompletionBanner(bulkActionCompletionText("delete", ids.length));
671
685
  }
672
686
  }, [
673
687
  pendingDelete,
@@ -676,6 +690,7 @@ export const MessageList = ({
676
690
  exitSelection,
677
691
  navigate,
678
692
  mailboxId,
693
+ isDesktop,
679
694
  runChunkedConfirmDelete,
680
695
  ]);
681
696
 
@@ -13,7 +13,10 @@ import type { JSDOM } from "jsdom";
13
13
  import { act, createElement, createRef, useState } from "react";
14
14
  import { createRoot, type Root } from "react-dom/client";
15
15
  import type { MessageListCommands } from "./MessageList";
16
- import { ThreadListInteraction } from "./ThreadListInteraction";
16
+ import {
17
+ ThreadListInteraction,
18
+ useThreadListSelection,
19
+ } from "./ThreadListInteraction";
17
20
 
18
21
  let dom: JSDOM;
19
22
  let container: HTMLElement;
@@ -197,3 +200,80 @@ describe("ThreadListInteraction — delete confirms first", () => {
197
200
  });
198
201
  });
199
202
  });
203
+
204
+ /**
205
+ * A background refresh drops the ids that left and keeps every survivor — the
206
+ * same intersect-on-refresh guarantee `useSelection.test.ts` locks for the pure
207
+ * helper (#111), here through the live provider whose effect runs it whenever
208
+ * the rendered rows change (a chip filter, a collapsed section, mail deleted
209
+ * elsewhere).
210
+ */
211
+ function mountSelectableList(initialIds: string[]) {
212
+ const commandsRef = createRef<MessageListCommands | null>();
213
+ let setIds: ((ids: string[]) => void) | undefined;
214
+ let selected: string[] = [];
215
+ const Probe = () => {
216
+ const { selectedIds } = useThreadListSelection();
217
+ selected = Array.from(selectedIds).sort();
218
+ return null;
219
+ };
220
+ const Harness = () => {
221
+ const [ids, set] = useState(initialIds);
222
+ setIds = set;
223
+ return createElement(
224
+ ThreadListInteraction,
225
+ { selectedMessageId: undefined, onOpen: () => undefined, commandsRef },
226
+ ...rowElements(ids),
227
+ createElement(Probe, { key: "probe" }),
228
+ );
229
+ };
230
+ act(() => root.render(createElement(Harness)));
231
+ return {
232
+ commands: () => {
233
+ const commands = commandsRef.current;
234
+ if (!commands) throw new Error("commands not published");
235
+ return commands;
236
+ },
237
+ render: async (ids: string[]) => {
238
+ await act(async () => {
239
+ setIds?.(ids);
240
+ });
241
+ },
242
+ selected: () => selected,
243
+ };
244
+ }
245
+
246
+ describe("ThreadListInteraction — selection survives a background refresh (#111)", () => {
247
+ it("keeps every survivor when a refresh drops one selected row", async () => {
248
+ const list = mountSelectableList(["m1", "m2", "m3"]);
249
+
250
+ // jsdom has no matchMedia, so the provider runs in mobile multi-select
251
+ // mode: focusFirst seeds the cursor, x toggles it in, and focusLast then
252
+ // toggles the row it lands on straight into the selection.
253
+ act(() => list.commands().focusFirst());
254
+ act(() => list.commands().toggleSelect());
255
+ act(() => list.commands().focusLast());
256
+ assert.deepEqual(list.selected(), ["m1", "m3"]);
257
+
258
+ // m3 leaves the rendered set (deleted elsewhere / filtered out); m2 is
259
+ // present but was never selected.
260
+ await list.render(["m1", "m2"]);
261
+
262
+ assert.deepEqual(
263
+ list.selected(),
264
+ ["m1"],
265
+ "the survivor stays selected, the departed id is dropped, and nothing new is added",
266
+ );
267
+ });
268
+
269
+ it("empties the selection only when every selected row leaves", async () => {
270
+ const list = mountSelectableList(["m1", "m2"]);
271
+ act(() => list.commands().focusFirst());
272
+ act(() => list.commands().toggleSelect());
273
+ assert.deepEqual(list.selected(), ["m1"]);
274
+
275
+ await list.render(["m9"]);
276
+
277
+ assert.deepEqual(list.selected(), []);
278
+ });
279
+ });