@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
|
@@ -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
|
-
/**
|
|
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
|
|
110
|
-
return act(() =>
|
|
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
|
|
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
|
|
162
|
+
await advance(THRESHOLD - 1);
|
|
142
163
|
pointerUp();
|
|
143
|
-
await
|
|
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
|
|
177
|
+
await advance(THRESHOLD - 1);
|
|
157
178
|
pointerCancel(row);
|
|
158
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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
|
|
242
|
-
// clears react-aria's own transient post-touch contextmenu
|
|
243
|
-
// which it removes shortly after pointerup — in a browser a
|
|
244
|
-
// arrives long after that
|
|
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
|
|
270
|
+
await advance(AFTER_ARIA_CONTEXTMENU_TEARDOWN);
|
|
250
271
|
|
|
251
272
|
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
252
273
|
});
|