@remit/ui 0.0.94 → 0.0.96

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.94",
3
+ "version": "0.0.96",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -5,7 +5,7 @@
5
5
  import assert from "node:assert/strict";
6
6
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
7
7
  import type { JSDOM } from "jsdom";
8
- import { act, createElement, useMemo } from "react";
8
+ import { act, createElement } from "react";
9
9
  import { createRoot, type Root } from "react-dom/client";
10
10
  import { LIST_ROW_SELECTOR } from "../lib/roving-focus.js";
11
11
  import {
@@ -76,6 +76,7 @@ before(async () => {
76
76
  globalThis.HTMLElement = dom.window.HTMLElement;
77
77
  globalThis.Element = dom.window.Element;
78
78
  globalThis.KeyboardEvent = dom.window.KeyboardEvent;
79
+ globalThis.MutationObserver = dom.window.MutationObserver;
79
80
  Object.defineProperty(globalThis, "navigator", {
80
81
  value: dom.window.navigator,
81
82
  configurable: true,
@@ -178,16 +179,10 @@ describe("BriefSections arrow-key traversal", () => {
178
179
  });
179
180
  });
180
181
 
181
- const orderedIds = sections.flatMap((section) =>
182
- section.threads.map((thread) => thread.id),
183
- );
184
-
185
182
  let list: ListKeyboard | undefined;
186
183
 
187
184
  function BriefUnderLayer() {
188
- const ids = useMemo(() => orderedIds, []);
189
185
  const keyboard = useListKeyboard({
190
- orderedIds: ids,
191
186
  isDesktop: true,
192
187
  initialFocusedId: "t1",
193
188
  });
@@ -245,9 +245,11 @@ export const SpamActionUnavailable: Story = {
245
245
  * action (a report against a message already in Junk is a real no-op-move,
246
246
  * issue #648), so without a pending label the button gives no visible
247
247
  * response at all until the request lands — the dead-button failure the
248
- * coding standards call the worst outcome. The button stays clickable
249
- * throughout, same as the undo direction's `isUndoing`: the operation is
250
- * idempotent, so a second press is safe, never a queued duplicate.
248
+ * coding standards call the worst outcome. The button is disabled for the
249
+ * duration: the server dedupes message ids only within a single request, so
250
+ * a second press mid-flight would fire a second, concurrent request rather
251
+ * than join the first (issue #648 review). Still visibly a button, never
252
+ * dead — the "Reporting…" label carries that.
251
253
  */
252
254
  export const ReportSpamPending: Story = {
253
255
  args: {
@@ -188,10 +188,12 @@ export interface IntelligencePanelProps {
188
188
  * update for this action (issue #648: the client can't predict whether a
189
189
  * report against a message already in Junk is a no-op), so without this
190
190
  * the button gives no visible response at all until the request lands — a
191
- * dead button is the worst outcome. Swaps the label to "Reporting…"; the
192
- * handler stays wired throughout (same as `AutoMovedBadge`'s `isUndoing`)
193
- * because the operation is idempotent, so a second press mid-flight is
194
- * safe rather than a queued duplicate.
191
+ * dead button is the worst outcome. Swaps the label to "Reporting…" AND
192
+ * disables the button: the server dedupes message ids only within a
193
+ * single request, so a second press mid-flight fires a second, concurrent
194
+ * request rather than joining the first (issue #648 review — for "Not
195
+ * spam" this races the restore move and can surface a spurious
196
+ * "try again in a moment" for an undo that already succeeded).
195
197
  */
196
198
  reportSpamPending?: boolean;
197
199
  /** True while a "Not spam" (undo) press is in flight. Same treatment as `reportSpamPending`. */
@@ -336,17 +338,21 @@ function QuickAction({
336
338
  active,
337
339
  danger,
338
340
  onClick,
341
+ pending,
339
342
  }: {
340
343
  icon: ReactNode;
341
344
  label: string;
342
345
  active?: boolean;
343
346
  danger?: boolean;
344
347
  onClick?: () => void;
348
+ /** Disable the button without hiding it — a request for this action is already in flight. */
349
+ pending?: boolean;
345
350
  }) {
346
351
  // No handler means the action cannot be serviced yet (the sender's address
347
352
  // record has not resolved). Render it visibly unavailable rather than as a
348
- // live button that swallows the click.
349
- const disabled = onClick === undefined;
353
+ // live button that swallows the click. `pending` disables it for the same
354
+ // reason once a press is in flight.
355
+ const disabled = onClick === undefined || pending === true;
350
356
  return (
351
357
  <button
352
358
  type="button"
@@ -465,6 +471,7 @@ export function IntelligencePanel({
465
471
  label={notSpamPending ? "Undoing…" : "Not spam"}
466
472
  active
467
473
  onClick={actions.onNotSpam}
474
+ pending={notSpamPending}
468
475
  />
469
476
  )}
470
477
  {actions?.onReportSpam && (
@@ -473,6 +480,7 @@ export function IntelligencePanel({
473
480
  label={reportSpamPending ? "Reporting…" : "Report spam"}
474
481
  danger
475
482
  onClick={actions.onReportSpam}
483
+ pending={reportSpamPending}
476
484
  />
477
485
  )}
478
486
  <QuickAction
@@ -101,8 +101,7 @@ const noKeyboard: MessageListKeyboard = {
101
101
  * footer offers what is wired.
102
102
  */
103
103
  function LiveList({ briefFilters = false }: { briefFilters?: boolean }) {
104
- const orderedIds = sections.flatMap((s) => s.threads).map((t) => t.id);
105
- const list = useListKeyboard({ orderedIds, isDesktop: true });
104
+ const list = useListKeyboard({ isDesktop: true });
106
105
  return (
107
106
  <MessageListPane
108
107
  listTitle="Inbox"
@@ -258,9 +257,9 @@ function SelectableList({ isDesktop }: { isDesktop: boolean }) {
258
257
  readIds.has(thread.id) ? { ...thread, isRead: true } : thread,
259
258
  ),
260
259
  }));
261
- const orderedIds = visible.flatMap((s) => s.threads).map((t) => t.id);
262
- const list = useListKeyboard({ orderedIds, isDesktop });
260
+ const list = useListKeyboard({ isDesktop });
263
261
  const { selection } = list.cursor;
262
+ const { orderedIds } = list;
264
263
  const allSelected =
265
264
  orderedIds.length > 0 &&
266
265
  orderedIds.every((id) => selection.selectedIds.has(id));
@@ -325,6 +325,7 @@ export function SwipeableRow({
325
325
  role={selectionMode ? "checkbox" : undefined}
326
326
  aria-checked={selectionMode ? checked : undefined}
327
327
  data-message-row
328
+ data-message-id={thread.id}
328
329
  {...gestureProps}
329
330
  className={interactiveClassName}
330
331
  style={interactiveStyle}
package/src/index.ts CHANGED
@@ -748,6 +748,10 @@ export {
748
748
  type UseLongPressResult,
749
749
  useLongPress,
750
750
  } from "./lib/use-long-press.js";
751
+ export {
752
+ MESSAGE_ROW_SELECTOR,
753
+ useRenderedRowIds,
754
+ } from "./lib/use-rendered-row-ids.js";
751
755
  export {
752
756
  computeRange,
753
757
  deriveIsMultiSelectMode,
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The list's keyboard layer as a host mounts it: the keys reach the cursor from
3
3
  * inside the element the layer was given and from nowhere else, the row-click
4
- * path reads its modifiers the same way, the selection follows the rows it is
5
- * handed, and the footer offers only the actions the layer registered.
4
+ * path reads its modifiers the same way, the selection follows the rows on
5
+ * screen, and the footer offers only the actions the layer registered.
6
6
  */
7
7
  import assert from "node:assert/strict";
8
8
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
@@ -18,14 +18,32 @@ let container: HTMLElement;
18
18
  let root: Root;
19
19
  let list: ListKeyboard;
20
20
 
21
- function Harness({ orderedIds }: { orderedIds: string[] }) {
22
- list = useListKeyboard({ orderedIds, isDesktop: true });
23
- return createElement("section", { id: "pane", ref: list.keyboard.ref });
21
+ function Harness({ rowIds }: { rowIds: string[] }) {
22
+ list = useListKeyboard({ isDesktop: true });
23
+ return createElement(
24
+ "section",
25
+ { id: "pane", ref: list.keyboard.ref },
26
+ ...rowIds.map((id) =>
27
+ createElement("button", {
28
+ key: id,
29
+ type: "button",
30
+ "data-message-id": id,
31
+ }),
32
+ ),
33
+ );
24
34
  }
25
35
 
26
- const mount = (orderedIds: string[] = ALL_IDS) => {
36
+ const mount = (rowIds: string[] = ALL_IDS) => {
27
37
  act(() => {
28
- root.render(createElement(Harness, { orderedIds }));
38
+ root.render(createElement(Harness, { rowIds }));
39
+ });
40
+ };
41
+
42
+ // The layer reads the rendered rows through a MutationObserver, whose callback
43
+ // lands on the microtask queue — an async act flushes both.
44
+ const rerender = async (rowIds: string[]) => {
45
+ await act(async () => {
46
+ root.render(createElement(Harness, { rowIds }));
29
47
  });
30
48
  };
31
49
 
@@ -82,6 +100,7 @@ before(async () => {
82
100
  globalThis.HTMLElement = dom.window.HTMLElement;
83
101
  globalThis.Element = dom.window.Element;
84
102
  globalThis.SVGElement = dom.window.SVGElement;
103
+ globalThis.MutationObserver = dom.window.MutationObserver;
85
104
  (
86
105
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
87
106
  ).IS_REACT_ACT_ENVIRONMENT = true;
@@ -159,14 +178,35 @@ describe("useListKeyboard", () => {
159
178
  assert.deepEqual(selected(), []);
160
179
  });
161
180
 
162
- it("drops the ticked rows that leave the list, and keeps the rest", () => {
181
+ it("drops the ticked rows that leave the screen, and keeps the rest", async () => {
163
182
  press("a", { metaKey: true });
164
183
  assert.deepEqual(selected(), ALL_IDS);
165
- mount(["m1", "m3"]);
184
+ await rerender(["m1", "m3"]);
166
185
  assert.deepEqual(
167
186
  selected(),
168
187
  ["m1", "m3"],
169
188
  "a verb acts on the rows that are still on screen",
170
189
  );
171
190
  });
191
+
192
+ it("walks and takes only the rows the pane is rendering", async () => {
193
+ await rerender(["m1", "m2"]);
194
+
195
+ press("a", { metaKey: true });
196
+ assert.deepEqual(
197
+ selected(),
198
+ ["m1", "m2"],
199
+ "⌘A stops at the rows on screen",
200
+ );
201
+
202
+ press("Escape");
203
+ press("j");
204
+ press("j");
205
+ press("j");
206
+ assert.equal(
207
+ list.keyboard.focusedId,
208
+ "m2",
209
+ "the cursor stops at the last rendered row",
210
+ );
211
+ });
172
212
  });
@@ -10,7 +10,10 @@
10
10
  *
11
11
  * The layer binds its keys to the pane element rather than the window, so a
12
12
  * page carrying several lists gives each of them only the keys pressed inside
13
- * it.
13
+ * it. It takes the rows it walks from that same element rather than from the
14
+ * caller's data: a section behind "Show N more", a collapsed header and a
15
+ * category scope all take rows off the screen without touching the data, and a
16
+ * cursor or a count built from the data reaches them anyway.
14
17
  */
15
18
  import { useEffect, useMemo, useState } from "react";
16
19
  import type {
@@ -19,11 +22,12 @@ import type {
19
22
  } from "../components/app-shell-types.js";
20
23
  import type { TriageHandlers } from "./keymap.js";
21
24
  import { type ListCursor, useListCursor } from "./use-list-cursor.js";
25
+ import { useRenderedRowIds } from "./use-rendered-row-ids.js";
22
26
  import { useTriageKeyboard } from "./use-triage-keyboard.js";
23
27
 
28
+ const NO_ROWS: string[] = [];
29
+
24
30
  export interface UseListKeyboardOptions {
25
- /** Row ids in display order. */
26
- orderedIds: string[];
27
31
  isDesktop: boolean;
28
32
  /** Seeds the cursor — normally the open thread. */
29
33
  initialFocusedId?: string;
@@ -35,6 +39,11 @@ export interface UseListKeyboardOptions {
35
39
 
36
40
  export interface ListKeyboard {
37
41
  cursor: ListCursor;
42
+ /**
43
+ * The rows the pane is rendering, in display order — what the keys walk,
44
+ * what ⌘A takes, and what a select-all checkbox above the list counts.
45
+ */
46
+ orderedIds: string[];
38
47
  /** The pane's `selection` prop. */
39
48
  selection: MessageListSelection;
40
49
  /** The pane's `keyboard` prop. */
@@ -42,13 +51,14 @@ export interface ListKeyboard {
42
51
  }
43
52
 
44
53
  export const useListKeyboard = ({
45
- orderedIds,
46
54
  isDesktop,
47
55
  initialFocusedId,
48
56
  initialSelectedIds,
49
57
  enabled = true,
50
58
  }: UseListKeyboardOptions): ListKeyboard => {
51
59
  const [pane, setPane] = useState<HTMLElement | null>(null);
60
+ const renderedIds = useRenderedRowIds(pane);
61
+ const orderedIds = renderedIds ?? NO_ROWS;
52
62
 
53
63
  const cursor = useListCursor({
54
64
  orderedIds,
@@ -70,13 +80,16 @@ export const useListKeyboard = ({
70
80
  };
71
81
  useTriageKeyboard({ handlers, enabled, target: pane });
72
82
 
73
- // A row that leaves the list — a filter, an account pill, a completed verb —
74
- // cannot stay selected, or the count and the verbs act on rows nobody can
75
- // see. The same rule the app runs in `ThreadListInteraction`.
83
+ // A row that leaves the screen — a filter, an account pill, a collapsed
84
+ // section, a completed verb — cannot stay selected, or the count and the
85
+ // verbs act on rows nobody can see. The same rule the app runs in
86
+ // `ThreadListInteraction`. Rows that have not been read yet are not rows that
87
+ // left, so the seeded selection survives the first render.
76
88
  const { intersectWith } = cursor.selection;
77
89
  useEffect(() => {
78
- intersectWith(orderedIds);
79
- }, [intersectWith, orderedIds]);
90
+ if (renderedIds === undefined) return;
91
+ intersectWith(renderedIds);
92
+ }, [intersectWith, renderedIds]);
80
93
 
81
94
  const { selectedIds, toggle } = cursor.selection;
82
95
  const { handleRowSelect } = cursor;
@@ -91,6 +104,7 @@ export const useListKeyboard = ({
91
104
 
92
105
  return {
93
106
  cursor,
107
+ orderedIds,
94
108
  selection,
95
109
  keyboard: {
96
110
  focusedId: cursor.focusedMessageId,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The rows a message list is actually showing, read from the DOM.
3
+ *
4
+ * A list narrows itself in ways its data never records: a section caps itself
5
+ * behind "Show N more", collapses from its own header, or falls out of a
6
+ * category scope. The ids a consumer hands down are therefore not the ids on
7
+ * screen, and a cursor or a selection built from them reaches rows nobody can
8
+ * see. Reading the rendered rows is the one answer that holds for every kind of
9
+ * narrowing, wherever it happens.
10
+ */
11
+ import { useCallback, useEffect, useState } from "react";
12
+
13
+ /** The marker a row carries the id the cursor and the selection know it by. */
14
+ export const MESSAGE_ROW_SELECTOR = "[data-message-id]";
15
+
16
+ const readRowIds = (container: HTMLElement): string[] =>
17
+ Array.from(container.querySelectorAll<HTMLElement>(MESSAGE_ROW_SELECTOR))
18
+ .map((row) => row.dataset.messageId)
19
+ .filter((id): id is string => id !== undefined);
20
+
21
+ const sameIds = (a: string[], b: string[]): boolean =>
22
+ a.length === b.length && a.every((id, index) => id === b[index]);
23
+
24
+ /**
25
+ * The ids of the rows inside `container`, in document order, kept in step with
26
+ * what it renders. `undefined` until a container has been read — a list whose
27
+ * rows have not been counted yet is a different answer from a list with no
28
+ * rows, and only the second one may empty a selection.
29
+ *
30
+ */
31
+ export function useRenderedRowIds(
32
+ container: HTMLElement | null,
33
+ ): string[] | undefined {
34
+ const [rowIds, setRowIds] = useState<string[] | undefined>(undefined);
35
+
36
+ const sync = useCallback(() => {
37
+ if (!container) return;
38
+ const next = readRowIds(container);
39
+ setRowIds((prev) => (prev && sameIds(prev, next) ? prev : next));
40
+ }, [container]);
41
+
42
+ // Rows this render moved — a chip, an account pill, a completed verb — are in
43
+ // the DOM by the time the commit's effects run.
44
+ useEffect(sync);
45
+
46
+ // Rows a section moves on its own — "Show N more", a collapsing header — never
47
+ // reach this render at all, so nothing but the DOM reports them.
48
+ useEffect(() => {
49
+ if (!container) return;
50
+ const observer = new MutationObserver(sync);
51
+ observer.observe(container, { childList: true, subtree: true });
52
+ return () => observer.disconnect();
53
+ }, [container, sync]);
54
+
55
+ return rowIds;
56
+ }