@remit/ui 0.0.140 → 0.0.142

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.140",
3
+ "version": "0.0.142",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,167 @@
1
+ import "@remit/test-dom";
2
+ import assert from "node:assert/strict";
3
+ import { afterEach, beforeEach, describe, it } from "node:test";
4
+ import { act, createElement, useState } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import type { FolderRole } from "./folder-role.js";
7
+ import {
8
+ type CandidateFolder,
9
+ type RoleAppointment,
10
+ RoleAppointmentList,
11
+ } from "./role-appointment-list.js";
12
+
13
+ const folders: CandidateFolder[] = [
14
+ {
15
+ mailboxId: "mb-inbox",
16
+ providerPath: "INBOX",
17
+ hierarchyDelimiter: "/",
18
+ messageCount: 4821,
19
+ },
20
+ {
21
+ mailboxId: "mb-deleted",
22
+ providerPath: "INBOX/Deleted Messages",
23
+ hierarchyDelimiter: "/",
24
+ messageCount: 512,
25
+ },
26
+ {
27
+ mailboxId: "mb-concepten",
28
+ providerPath: "INBOX/Concepten",
29
+ hierarchyDelimiter: "/",
30
+ messageCount: 340,
31
+ },
32
+ ];
33
+
34
+ let container: HTMLElement;
35
+ let root: Root;
36
+ let appointed: Array<[FolderRole, string | null]>;
37
+
38
+ beforeEach(() => {
39
+ appointed = [];
40
+ container = document.createElement("div");
41
+ document.body.append(container);
42
+ root = createRoot(container);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await act(async () => {
47
+ root.unmount();
48
+ });
49
+ container.remove();
50
+ });
51
+
52
+ const mount = async (appointments: Record<string, RoleAppointment>) => {
53
+ await act(async () => {
54
+ root.render(
55
+ createElement(RoleAppointmentList, {
56
+ accountEmail: "you@example.com",
57
+ folders,
58
+ appointments,
59
+ displayNames: {},
60
+ onAppoint: (role: FolderRole, mailboxId: string | null) => {
61
+ appointed.push([role, mailboxId]);
62
+ },
63
+ onRename: () => {},
64
+ }),
65
+ );
66
+ });
67
+ };
68
+
69
+ const buttonNamed = (text: string): HTMLButtonElement | undefined =>
70
+ [...container.querySelectorAll("button")].find(
71
+ (button) => button.textContent?.trim() === text,
72
+ );
73
+
74
+ const click = async (element: Element | undefined) => {
75
+ assert.ok(element, "the control is rendered");
76
+ await act(async () => {
77
+ element.dispatchEvent(
78
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
79
+ );
80
+ });
81
+ };
82
+
83
+ describe("confirming a proposed folder role", () => {
84
+ it("commits the folder the picker already shows", async () => {
85
+ await mount({
86
+ trash: { mailboxId: "mb-deleted", source: "Proposed" },
87
+ });
88
+ await click(buttonNamed("Set as Trash"));
89
+ assert.deepEqual(appointed, [["trash", "mb-deleted"]]);
90
+ });
91
+
92
+ it("offers the commit on every proposed role, naming that role", async () => {
93
+ await mount({
94
+ trash: { mailboxId: "mb-deleted", source: "Proposed" },
95
+ drafts: { mailboxId: "mb-concepten", source: "Proposed" },
96
+ });
97
+ await click(buttonNamed("Set as Drafts"));
98
+ assert.deepEqual(appointed, [["drafts", "mb-concepten"]]);
99
+ });
100
+
101
+ it("leaves a vouched row alone — there is nothing to confirm", async () => {
102
+ await mount({
103
+ trash: { mailboxId: "mb-deleted", source: "Appointed" },
104
+ drafts: { mailboxId: "mb-concepten", source: "Flagged" },
105
+ inbox: { mailboxId: "mb-inbox", source: "Reserved" },
106
+ });
107
+ assert.equal(buttonNamed("Set as Trash"), undefined);
108
+ assert.equal(buttonNamed("Set as Drafts"), undefined);
109
+ assert.equal(buttonNamed("Set as Inbox"), undefined);
110
+ });
111
+
112
+ it("offers nothing to commit when the proposal resolves to no folder", async () => {
113
+ await mount({ trash: { mailboxId: null, source: "Proposed" } });
114
+ assert.equal(buttonNamed("Set as Trash"), undefined);
115
+ });
116
+ });
117
+
118
+ function Controlled({ accepts }: { accepts: boolean }) {
119
+ const [appointments, setAppointments] = useState<
120
+ Record<string, RoleAppointment>
121
+ >({ trash: { mailboxId: "mb-deleted", source: "Proposed" } });
122
+ return createElement(RoleAppointmentList, {
123
+ accountEmail: "you@example.com",
124
+ folders,
125
+ appointments,
126
+ displayNames: {},
127
+ onAppoint: (role: FolderRole, mailboxId: string | null) => {
128
+ appointed.push([role, mailboxId]);
129
+ if (!accepts || mailboxId === null) return;
130
+ setAppointments({ [role]: { mailboxId, source: "Appointed" } });
131
+ },
132
+ onRename: () => {},
133
+ });
134
+ }
135
+
136
+ describe("where focus goes when a proposal is committed", () => {
137
+ const mountControlled = async (accepts: boolean) => {
138
+ await act(async () => {
139
+ root.render(createElement(Controlled, { accepts }));
140
+ });
141
+ };
142
+
143
+ const trashPicker = (): Element | null =>
144
+ container.querySelector('select[aria-label="Folder for Trash"]');
145
+
146
+ it("hands focus to the picker once the role re-resolves", async () => {
147
+ await mountControlled(true);
148
+ const button = buttonNamed("Set as Trash");
149
+ button?.focus();
150
+ await click(button);
151
+ assert.equal(
152
+ buttonNamed("Set as Trash"),
153
+ undefined,
154
+ "the commit removes itself",
155
+ );
156
+ assert.equal(document.activeElement, trashPicker());
157
+ });
158
+
159
+ it("leaves focus on the commit when the role does not re-resolve", async () => {
160
+ await mountControlled(false);
161
+ const button = buttonNamed("Set as Trash");
162
+ button?.focus();
163
+ await click(button);
164
+ assert.equal(buttonNamed("Set as Trash"), button);
165
+ assert.equal(document.activeElement, button);
166
+ });
167
+ });
@@ -199,7 +199,11 @@ export const ReservedSource: Story = {
199
199
  },
200
200
  };
201
201
 
202
- /** `Proposed` — a name matched. Nobody confirmed it, and the row does not pretend otherwise. */
202
+ /**
203
+ * `Proposed` — a name matched. Nobody confirmed it, and the row does not
204
+ * pretend otherwise. `Set as Trash` commits the folder the picker already
205
+ * shows, which re-picking that same option cannot do.
206
+ */
203
207
  export const ProposedSource: Story = {
204
208
  name: "proposed",
205
209
  args: {
@@ -211,6 +215,26 @@ export const ProposedSource: Story = {
211
215
  },
212
216
  };
213
217
 
218
+ /**
219
+ * Density check: a server that advertises no SPECIAL-USE flags, so every role
220
+ * but INBOX rests on a name match alone. Each row carries its own commit, so
221
+ * the screen stays a list rather than five stacked callouts.
222
+ */
223
+ export const ProposedThroughout: Story = {
224
+ name: "proposed throughout",
225
+ args: {
226
+ folders: HOSTNET_FOLDERS,
227
+ initial: {
228
+ inbox: { mailboxId: "mb-inbox", source: "Reserved" },
229
+ drafts: { mailboxId: "mb-concepten", source: "Proposed" },
230
+ sent: { mailboxId: "mb-sent-messages", source: "Proposed" },
231
+ archive: { mailboxId: "mb-archive", source: "Proposed" },
232
+ junk: { mailboxId: "mb-spam", source: "Proposed" },
233
+ trash: { mailboxId: "mb-deleted", source: "Proposed" },
234
+ },
235
+ },
236
+ };
237
+
214
238
  /**
215
239
  * `Stale` — the folder the user chose is gone from the mail server. The one row
216
240
  * representing a broken decision, so it is a callout with its repair rather
@@ -1,5 +1,5 @@
1
1
  import { Check, Folder } from "lucide-react";
2
- import { useId, useRef, useState } from "react";
2
+ import { useEffect, useId, useRef, useState } from "react";
3
3
  import { folderLeaf } from "../lib/folder-tree.js";
4
4
  import { Banner } from "./banner.js";
5
5
  import { Button } from "./button.js";
@@ -119,6 +119,7 @@ function RoleAppointmentRow({
119
119
  }: RoleAppointmentRowProps) {
120
120
  const [draftName, setDraftName] = useState(displayName);
121
121
  const selectRef = useRef<HTMLSelectElement>(null);
122
+ const confirmingProposal = useRef(false);
122
123
  const subtitleId = useId();
123
124
  const { mailboxId, source, staleFolderPath } = appointment;
124
125
  const appointed = folders.find((f) => f.mailboxId === mailboxId) ?? null;
@@ -126,6 +127,12 @@ function RoleAppointmentRow({
126
127
  const renameDirty =
127
128
  appointed != null && draftName.trim() !== displayName.trim();
128
129
 
130
+ useEffect(() => {
131
+ if (!confirmingProposal.current || source === "Proposed") return;
132
+ confirmingProposal.current = false;
133
+ selectRef.current?.focus();
134
+ }, [source]);
135
+
129
136
  const subtitle = (() => {
130
137
  if (source === "None") {
131
138
  const needsOne = role === "trash" ? " Deleting mail needs one." : "";
@@ -194,6 +201,19 @@ function RoleAppointmentRow({
194
201
  </option>
195
202
  ))}
196
203
  </Select>
204
+ {source === "Proposed" && appointed && (
205
+ <Button
206
+ variant="secondary"
207
+ size="sm"
208
+ className="shrink-0"
209
+ onClick={() => {
210
+ confirmingProposal.current = true;
211
+ onAppoint(role, appointed.mailboxId);
212
+ }}
213
+ >
214
+ {`Set as ${label}`}
215
+ </Button>
216
+ )}
197
217
  {appointed && (
198
218
  <>
199
219
  <Input
@@ -12,17 +12,25 @@
12
12
  * need a real `document`/`window`/`PointerEvent`, which `renderToString`
13
13
  * (the pattern used elsewhere in this repo for presentational components)
14
14
  * cannot exercise.
15
+ *
16
+ * The clock is mocked. Every timing assertion here is about one boundary —
17
+ * the press crossed the threshold, or it ended first — and racing that
18
+ * boundary against a wall clock on a loaded runner turns a passing test red
19
+ * (#645). Time only moves when `advance` moves it.
15
20
  */
16
21
 
17
22
  import "@remit/test-dom";
18
23
  import assert from "node:assert/strict";
19
- import { afterEach, beforeEach, describe, it } from "node:test";
24
+ import { afterEach, beforeEach, describe, it, mock } from "node:test";
20
25
  import { act, createElement } from "react";
21
26
  import { createRoot, type Root } from "react-dom/client";
22
27
  import { useLongPress } from "./use-long-press.js";
23
28
 
24
29
  const THRESHOLD = 40;
25
30
 
31
+ /** Past react-aria's own teardown of its transient post-pointerup contextmenu listener. */
32
+ const AFTER_ARIA_CONTEXTMENU_TEARDOWN = 100;
33
+
26
34
  let container: HTMLElement;
27
35
  let root: Root;
28
36
 
@@ -106,17 +114,27 @@ function pointerCancel(row: Element) {
106
114
  row.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true }));
107
115
  }
108
116
 
109
- function wait(ms: number) {
110
- return act(() => new Promise((resolve) => setTimeout(resolve, ms)));
117
+ function advance(ms: number) {
118
+ return act(() => {
119
+ mock.timers.tick(ms);
120
+ });
111
121
  }
112
122
 
113
123
  beforeEach(() => {
114
124
  container = document.getElementById("root") as unknown as HTMLElement;
115
125
  container.innerHTML = "";
116
126
  root = createRoot(container);
127
+ mock.timers.enable({ apis: ["setTimeout"] });
117
128
  });
118
129
 
119
130
  afterEach(() => {
131
+ // The hook's contextmenu arming is module state bounded by a timer, and a
132
+ // test that leaves the finger down leaves it armed. Release the pointer so
133
+ // the next test starts disarmed rather than inheriting a dead timer id.
134
+ act(() => {
135
+ pointerUp();
136
+ });
137
+ mock.timers.reset();
120
138
  act(() => {
121
139
  root.unmount();
122
140
  });
@@ -128,7 +146,10 @@ describe("useLongPress (react-aria wrapper)", () => {
128
146
  const row = mount({ onLongPress: () => fired++ });
129
147
 
130
148
  pointerDown(row);
131
- await wait(THRESHOLD + 40);
149
+ await advance(THRESHOLD - 1);
150
+ assert.equal(fired, 0, "the threshold had not elapsed yet");
151
+
152
+ await advance(1);
132
153
 
133
154
  assert.equal(fired, 1);
134
155
  });
@@ -138,9 +159,9 @@ describe("useLongPress (react-aria wrapper)", () => {
138
159
  const row = mount({ onLongPress: () => fired++ });
139
160
 
140
161
  pointerDown(row);
141
- await wait(THRESHOLD / 2);
162
+ await advance(THRESHOLD - 1);
142
163
  pointerUp();
143
- await wait(THRESHOLD + 40);
164
+ await advance(THRESHOLD);
144
165
 
145
166
  assert.equal(fired, 0);
146
167
  });
@@ -153,9 +174,9 @@ describe("useLongPress (react-aria wrapper)", () => {
153
174
  const row = mount({ onLongPress: () => fired++ });
154
175
 
155
176
  pointerDown(row);
156
- await wait(THRESHOLD / 2);
177
+ await advance(THRESHOLD - 1);
157
178
  pointerCancel(row);
158
- await wait(THRESHOLD + 40);
179
+ await advance(THRESHOLD);
159
180
 
160
181
  assert.equal(fired, 0);
161
182
  });
@@ -165,7 +186,7 @@ describe("useLongPress (react-aria wrapper)", () => {
165
186
  const row = mount({ onLongPress: () => fired++, isDisabled: true });
166
187
 
167
188
  pointerDown(row);
168
- await wait(THRESHOLD + 40);
189
+ await advance(THRESHOLD);
169
190
 
170
191
  assert.equal(fired, 0);
171
192
  });
@@ -175,7 +196,7 @@ describe("useLongPress (react-aria wrapper)", () => {
175
196
  const row = mount({ onLongPress: () => fired++ });
176
197
 
177
198
  pointerDown(row);
178
- await wait(THRESHOLD + 40);
199
+ await advance(THRESHOLD);
179
200
  assert.equal(
180
201
  fired,
181
202
  1,
@@ -228,7 +249,7 @@ describe("useLongPress (react-aria wrapper)", () => {
228
249
  "the touch long-press menu is still suppressed",
229
250
  );
230
251
  pointerUpOn(row);
231
- await wait(THRESHOLD);
252
+ await advance(AFTER_ARIA_CONTEXTMENU_TEARDOWN);
232
253
 
233
254
  assert.equal(
234
255
  dispatchContextMenu(row).defaultPrevented,
@@ -238,15 +259,15 @@ describe("useLongPress (react-aria wrapper)", () => {
238
259
  });
239
260
 
240
261
  it("does not suppress the keyboard menu after a touch tap that raised no menu", async () => {
241
- // A tap that lifts without a menu must still disarm suppression. The wait
242
- // clears react-aria's own transient post-touch contextmenu listener,
243
- // which it removes shortly after pointerup — in a browser a keyboard menu
244
- // arrives long after that window, so only this hook's ref decides.
262
+ // A tap that lifts without a menu must still disarm suppression. The
263
+ // advance clears react-aria's own transient post-touch contextmenu
264
+ // listener, which it removes shortly after pointerup — in a browser a
265
+ // keyboard menu arrives long after that, so only this hook's ref decides.
245
266
  const row = mount({ onLongPress: () => undefined });
246
267
 
247
268
  pointerDown(row, "touch");
248
269
  pointerUpOn(row);
249
- await wait(THRESHOLD);
270
+ await advance(AFTER_ARIA_CONTEXTMENU_TEARDOWN);
250
271
 
251
272
  assert.equal(dispatchContextMenu(row).defaultPrevented, false);
252
273
  });