@remit/web-client 0.0.130 → 0.0.131
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.
|
|
3
|
+
"version": "0.0.131",
|
|
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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A modifier can only come from a real keyboard, so the touch row honours it at
|
|
3
|
+
* every width (#586).
|
|
4
|
+
*
|
|
5
|
+
* Below 1024px the list renders the swipe row, which reads a press as a pointer
|
|
6
|
+
* gesture and opens the message from the release. Shift and cmd have to reach
|
|
7
|
+
* selection before that gesture starts, or a half-screen window and a tablet
|
|
8
|
+
* with a keyboard can only ever open messages one at a time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
|
+
import type { SelectionModifiers } from "@remit/ui";
|
|
15
|
+
import {
|
|
16
|
+
type AnyRouter,
|
|
17
|
+
createMemoryHistory,
|
|
18
|
+
createRootRoute,
|
|
19
|
+
createRoute,
|
|
20
|
+
createRouter,
|
|
21
|
+
RouterContextProvider,
|
|
22
|
+
} from "@tanstack/react-router";
|
|
23
|
+
import { createElement } from "react";
|
|
24
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
25
|
+
import { SwipeableMessageRow } from "./SwipeableMessageRow";
|
|
26
|
+
|
|
27
|
+
const HALF_SCREEN_WIDTH = 900;
|
|
28
|
+
|
|
29
|
+
let harness: DomHarness | undefined;
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
harness?.close();
|
|
33
|
+
harness = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const thread = {
|
|
37
|
+
threadMessageId: "tm-1",
|
|
38
|
+
threadId: "th-1",
|
|
39
|
+
messageId: "msg-1",
|
|
40
|
+
accountId: "acc-1",
|
|
41
|
+
accountConfigId: "acc-1",
|
|
42
|
+
mailboxId: "mbx-1",
|
|
43
|
+
subject: "Q3 planning notes",
|
|
44
|
+
fromName: "Alex Rivera",
|
|
45
|
+
fromEmail: "alex@example.com",
|
|
46
|
+
snippet: "Notes from the planning session.",
|
|
47
|
+
sentDate: 0,
|
|
48
|
+
isRead: false,
|
|
49
|
+
hasAttachment: false,
|
|
50
|
+
hasStars: false,
|
|
51
|
+
star: "None",
|
|
52
|
+
isDeleted: false,
|
|
53
|
+
senderTrust: "unknown",
|
|
54
|
+
createdAt: 0,
|
|
55
|
+
updatedAt: 0,
|
|
56
|
+
} as unknown as RemitImapThreadMessageResponse;
|
|
57
|
+
|
|
58
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
59
|
+
// `window`.
|
|
60
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
61
|
+
|
|
62
|
+
const rootRoute = createRootRoute();
|
|
63
|
+
const mailRoute = createRoute({
|
|
64
|
+
getParentRoute: () => rootRoute,
|
|
65
|
+
path: "/mail/$mailboxId",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
interface Mounted {
|
|
69
|
+
row: HTMLElement;
|
|
70
|
+
router: AnyRouter;
|
|
71
|
+
selects: SelectionModifiers[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const mountRow = (selectionTakesIt = true): Mounted => {
|
|
75
|
+
const selects: SelectionModifiers[] = [];
|
|
76
|
+
const router = createRouter({
|
|
77
|
+
routeTree: rootRoute.addChildren([mailRoute]),
|
|
78
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
79
|
+
}) as unknown as AnyRouter;
|
|
80
|
+
const created = createDomHarness({ viewportWidth: HALF_SCREEN_WIDTH });
|
|
81
|
+
harness = created;
|
|
82
|
+
created.render(
|
|
83
|
+
createElement(RouterContextProvider, {
|
|
84
|
+
router,
|
|
85
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
86
|
+
children: createElement(SwipeableMessageRow, {
|
|
87
|
+
thread,
|
|
88
|
+
mailboxId: "mbx-1",
|
|
89
|
+
isSelected: false,
|
|
90
|
+
isChecked: false,
|
|
91
|
+
onToggleCheck: () => undefined,
|
|
92
|
+
onRowSelect: (_id: string, modifiers: SelectionModifiers) => {
|
|
93
|
+
selects.push(modifiers);
|
|
94
|
+
return selectionTakesIt;
|
|
95
|
+
},
|
|
96
|
+
isMultiSelectMode: false,
|
|
97
|
+
onLongPress: () => undefined,
|
|
98
|
+
isDesktop: false,
|
|
99
|
+
onDelete: () => undefined,
|
|
100
|
+
onToggleRead: () => undefined,
|
|
101
|
+
}),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
const row = created.query("button[data-message-row]");
|
|
105
|
+
assert.ok(row, "the swipe row did not mount");
|
|
106
|
+
return { row, router, selects };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const press = (
|
|
110
|
+
row: Element,
|
|
111
|
+
modifiers: Partial<SelectionModifiers> = {},
|
|
112
|
+
): PointerEvent => {
|
|
113
|
+
const event = new PointerEvent("pointerdown", {
|
|
114
|
+
bubbles: true,
|
|
115
|
+
cancelable: true,
|
|
116
|
+
pointerId: 1,
|
|
117
|
+
clientX: 10,
|
|
118
|
+
clientY: 10,
|
|
119
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
120
|
+
metaKey: modifiers.metaKey ?? false,
|
|
121
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
122
|
+
});
|
|
123
|
+
harness?.dispatch(row, event);
|
|
124
|
+
return event;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const release = (row: Element): void => {
|
|
128
|
+
harness?.dispatch(
|
|
129
|
+
row,
|
|
130
|
+
new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }),
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// A press whose default was taken still delivers a click, so the click has to
|
|
135
|
+
// be consumed too rather than reaching selection a second time.
|
|
136
|
+
const click = (
|
|
137
|
+
row: Element,
|
|
138
|
+
modifiers: Partial<SelectionModifiers> = {},
|
|
139
|
+
): void =>
|
|
140
|
+
harness?.dispatch(
|
|
141
|
+
row,
|
|
142
|
+
new MouseEvent("click", {
|
|
143
|
+
bubbles: true,
|
|
144
|
+
cancelable: true,
|
|
145
|
+
shiftKey: modifiers.shiftKey ?? false,
|
|
146
|
+
metaKey: modifiers.metaKey ?? false,
|
|
147
|
+
ctrlKey: modifiers.ctrlKey ?? false,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const openedMessageId = (router: AnyRouter): string | undefined =>
|
|
152
|
+
(router.state.location.search as { selectedMessageId?: string })
|
|
153
|
+
.selectedMessageId;
|
|
154
|
+
|
|
155
|
+
describe("SwipeableMessageRow — modifier selection below the desktop width", () => {
|
|
156
|
+
it("takes a shift-press for selection instead of opening the message", async () => {
|
|
157
|
+
const { row, router, selects } = mountRow();
|
|
158
|
+
|
|
159
|
+
const event = press(row, { shiftKey: true });
|
|
160
|
+
release(row);
|
|
161
|
+
await harness?.flush();
|
|
162
|
+
|
|
163
|
+
assert.deepEqual(selects, [
|
|
164
|
+
{ shiftKey: true, metaKey: false, ctrlKey: false },
|
|
165
|
+
]);
|
|
166
|
+
assert.equal(event.defaultPrevented, true);
|
|
167
|
+
assert.equal(openedMessageId(router), undefined);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("takes a cmd-press for selection instead of opening the message", async () => {
|
|
171
|
+
const { row, router, selects } = mountRow();
|
|
172
|
+
|
|
173
|
+
press(row, { metaKey: true });
|
|
174
|
+
release(row);
|
|
175
|
+
click(row, { metaKey: true });
|
|
176
|
+
await harness?.flush();
|
|
177
|
+
|
|
178
|
+
assert.deepEqual(selects, [
|
|
179
|
+
{ shiftKey: false, metaKey: true, ctrlKey: false },
|
|
180
|
+
]);
|
|
181
|
+
assert.equal(openedMessageId(router), undefined);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("drops the native text selection a shift-press would drag across rows", () => {
|
|
185
|
+
const { row } = mountRow();
|
|
186
|
+
const selection = harness?.window.getSelection();
|
|
187
|
+
assert.ok(selection, "jsdom exposes no selection");
|
|
188
|
+
const range = harness?.document.createRange();
|
|
189
|
+
assert.ok(range, "jsdom exposes no range");
|
|
190
|
+
range.selectNodeContents(row);
|
|
191
|
+
selection.removeAllRanges();
|
|
192
|
+
selection.addRange(range);
|
|
193
|
+
|
|
194
|
+
press(row, { shiftKey: true });
|
|
195
|
+
|
|
196
|
+
assert.equal(selection.rangeCount, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("suppresses the context menu a ctrl-press already spent on selection", () => {
|
|
200
|
+
const { row, selects } = mountRow();
|
|
201
|
+
|
|
202
|
+
press(row, { ctrlKey: true });
|
|
203
|
+
const menu = new MouseEvent("contextmenu", {
|
|
204
|
+
bubbles: true,
|
|
205
|
+
cancelable: true,
|
|
206
|
+
ctrlKey: true,
|
|
207
|
+
});
|
|
208
|
+
harness?.dispatch(row, menu);
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(selects, [
|
|
211
|
+
{ shiftKey: false, metaKey: false, ctrlKey: true },
|
|
212
|
+
]);
|
|
213
|
+
assert.equal(menu.defaultPrevented, true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("opens on an unmodified tap", async () => {
|
|
217
|
+
const { row, router, selects } = mountRow();
|
|
218
|
+
|
|
219
|
+
press(row);
|
|
220
|
+
release(row);
|
|
221
|
+
click(row);
|
|
222
|
+
await harness?.flush();
|
|
223
|
+
|
|
224
|
+
assert.deepEqual(selects, []);
|
|
225
|
+
assert.equal(openedMessageId(router), "msg-1");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("opens when selection declines the modified press", async () => {
|
|
229
|
+
const { row, router } = mountRow(false);
|
|
230
|
+
|
|
231
|
+
press(row, { metaKey: true });
|
|
232
|
+
release(row);
|
|
233
|
+
await harness?.flush();
|
|
234
|
+
|
|
235
|
+
assert.equal(openedMessageId(router), "msg-1");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -11,6 +11,7 @@ import { useCallback, useState } from "react";
|
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
13
|
import { MessageListItem } from "./MessageListItem";
|
|
14
|
+
import { useModifierSelect } from "./useModifierSelect";
|
|
14
15
|
|
|
15
16
|
interface MailboxLinkSearch {
|
|
16
17
|
selectedMessageId?: string;
|
|
@@ -111,6 +112,8 @@ export const SwipeableMessageRow = ({
|
|
|
111
112
|
});
|
|
112
113
|
}, [navigate, mailboxId, thread.messageId]);
|
|
113
114
|
|
|
115
|
+
const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
|
|
116
|
+
|
|
114
117
|
if (isDesktop || isMultiSelectMode) {
|
|
115
118
|
return (
|
|
116
119
|
<MessageListItem
|
|
@@ -131,18 +134,31 @@ export const SwipeableMessageRow = ({
|
|
|
131
134
|
);
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
// The swipe row reads the press as a pointer gesture, and it opens the message
|
|
138
|
+
// from the release — a `mousedown` handler on the row would already be behind
|
|
139
|
+
// it. Taking the modified press in the capture phase keeps it away from the
|
|
140
|
+
// gesture entirely, so a shift- or cmd-click selects instead of starting a
|
|
141
|
+
// swipe, a long press or an open.
|
|
134
142
|
return (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
143
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: the wrapper only intercepts mouse modifiers ahead of the row's own gesture; the row beneath keeps the button semantics and the whole keyboard path
|
|
144
|
+
<div
|
|
145
|
+
role="presentation"
|
|
146
|
+
onPointerDownCapture={modifierSelect.onMouseDown}
|
|
147
|
+
onClickCapture={modifierSelect.claimClick}
|
|
148
|
+
onContextMenu={modifierSelect.onContextMenu}
|
|
149
|
+
>
|
|
150
|
+
<SwipeableRow
|
|
151
|
+
thread={toThreadRowData(thread)}
|
|
152
|
+
selectionMode={false}
|
|
153
|
+
checked={false}
|
|
154
|
+
active={isSelected}
|
|
155
|
+
peek={peek}
|
|
156
|
+
onPeek={setPeek}
|
|
157
|
+
onToggleCheck={handleToggleCheck}
|
|
158
|
+
onLongPress={handleLongPress}
|
|
159
|
+
onOpen={handleOpen}
|
|
160
|
+
onAct={handleAct}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
147
163
|
);
|
|
148
164
|
};
|