@remit/web-client 0.0.207 → 0.0.208
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.208",
|
|
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": {
|
|
@@ -65,8 +65,40 @@ const FOLDERS = [
|
|
|
65
65
|
makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
|
|
66
66
|
];
|
|
67
67
|
|
|
68
|
+
// The desktop picker renders through a portal into document.body, so its
|
|
69
|
+
// content sits outside the render container these helpers normally scope to.
|
|
70
|
+
const pickerRoot = (dom: DomHarness): ParentNode => {
|
|
71
|
+
const trigger = dom.byLabel("Move to folder");
|
|
72
|
+
return trigger.getAttribute("aria-haspopup") === "dialog"
|
|
73
|
+
? dom.container
|
|
74
|
+
: dom.document.body;
|
|
75
|
+
};
|
|
76
|
+
|
|
68
77
|
const rowText = (dom: DomHarness): string[] =>
|
|
69
|
-
dom.
|
|
78
|
+
[...pickerRoot(dom).querySelectorAll("[role=treeitem]")].map(
|
|
79
|
+
(row) => row.textContent ?? "",
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const pickerText = (dom: DomHarness): string =>
|
|
83
|
+
pickerRoot(dom).textContent ?? "";
|
|
84
|
+
|
|
85
|
+
const pickerByLabel = (dom: DomHarness, label: string): HTMLElement => {
|
|
86
|
+
const found = pickerRoot(dom).querySelector(`[aria-label="${label}"]`);
|
|
87
|
+
if (!found) throw new Error(`no picker element labelled "${label}"`);
|
|
88
|
+
return found as HTMLElement;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const pickerByText = (
|
|
92
|
+
dom: DomHarness,
|
|
93
|
+
selector: string,
|
|
94
|
+
text: string,
|
|
95
|
+
): HTMLElement => {
|
|
96
|
+
const found = [...pickerRoot(dom).querySelectorAll(selector)].find((node) =>
|
|
97
|
+
(node.textContent ?? "").includes(text),
|
|
98
|
+
);
|
|
99
|
+
if (!found) throw new Error(`no picker ${selector} containing "${text}"`);
|
|
100
|
+
return found as HTMLElement;
|
|
101
|
+
};
|
|
70
102
|
|
|
71
103
|
describe("MoveToTrigger", () => {
|
|
72
104
|
it("reports itself as collapsed until it is opened", () => {
|
|
@@ -103,7 +135,7 @@ describe("MoveToTrigger", () => {
|
|
|
103
135
|
false,
|
|
104
136
|
);
|
|
105
137
|
|
|
106
|
-
dom.click(dom
|
|
138
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
107
139
|
assert.ok(rowText(dom).some((label) => label.includes("Clients")));
|
|
108
140
|
});
|
|
109
141
|
|
|
@@ -117,10 +149,10 @@ describe("MoveToTrigger", () => {
|
|
|
117
149
|
|
|
118
150
|
// Picking a folder also opens it, so the move is a separate press —
|
|
119
151
|
// otherwise the first tap would fire before anything nested was reachable.
|
|
120
|
-
dom.click(dom
|
|
152
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
121
153
|
assert.deepEqual(moved, []);
|
|
122
154
|
|
|
123
|
-
dom.click(dom
|
|
155
|
+
dom.click(pickerByText(dom, "button", "Move to Work"));
|
|
124
156
|
assert.deepEqual(moved, ["mbx-work"]);
|
|
125
157
|
assert.equal(
|
|
126
158
|
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
@@ -131,10 +163,10 @@ describe("MoveToTrigger", () => {
|
|
|
131
163
|
it("names the destination on the button that runs the move", () => {
|
|
132
164
|
const dom = mount({ mailboxes: FOLDERS });
|
|
133
165
|
dom.click(dom.byLabel("Move to folder"));
|
|
134
|
-
dom.click(dom
|
|
135
|
-
dom.click(dom
|
|
166
|
+
dom.click(pickerByLabel(dom, "Move to Work"));
|
|
167
|
+
dom.click(pickerByLabel(dom, "Move to Clients"));
|
|
136
168
|
|
|
137
|
-
assert.match(dom
|
|
169
|
+
assert.match(pickerText(dom), /Move to Clients/);
|
|
138
170
|
});
|
|
139
171
|
|
|
140
172
|
it("closes on Escape and on a click outside it", () => {
|
|
@@ -159,6 +191,56 @@ describe("MoveToTrigger", () => {
|
|
|
159
191
|
assert.equal(isOpen(), false);
|
|
160
192
|
});
|
|
161
193
|
|
|
194
|
+
it("renders the desktop picker outside its own subtree (#601)", () => {
|
|
195
|
+
// Regression: an in-place absolute popover is clipped by the reading
|
|
196
|
+
// pane's overflow-hidden shell and painted underneath the thread list.
|
|
197
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
198
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
199
|
+
|
|
200
|
+
const trigger = dom.byLabel("Move to folder");
|
|
201
|
+
const tree = pickerRoot(dom).querySelector("[role=tree]");
|
|
202
|
+
assert.ok(tree, "the picker is open");
|
|
203
|
+
assert.ok(
|
|
204
|
+
!trigger.parentElement?.contains(tree),
|
|
205
|
+
"the picker escapes the trigger's subtree via a portal",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("stays open when the press lands inside the portalled panel (#601)", () => {
|
|
210
|
+
// The panel is not in the trigger's subtree any more, so the outside-press
|
|
211
|
+
// guard has to recognise it by its own ref — otherwise the first press on
|
|
212
|
+
// a folder closes the picker before it can be picked.
|
|
213
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
214
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
215
|
+
|
|
216
|
+
dom.dispatch(
|
|
217
|
+
pickerByLabel(dom, "Move to Work"),
|
|
218
|
+
new dom.window.MouseEvent("mousedown", { bubbles: true }),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
assert.equal(
|
|
222
|
+
dom.byLabel("Move to folder").getAttribute("aria-expanded"),
|
|
223
|
+
"true",
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("takes the keyboard into the picker and hands it back on close", () => {
|
|
228
|
+
// Regression: with the panel on the body, Tab from the trigger walked on
|
|
229
|
+
// to the next toolbar button instead of entering the picker.
|
|
230
|
+
const dom = mount({ mailboxes: FOLDERS });
|
|
231
|
+
dom.click(dom.byLabel("Move to folder"));
|
|
232
|
+
|
|
233
|
+
const filter = pickerRoot(dom).querySelector('input[type="search"]');
|
|
234
|
+
assert.ok(filter, "the picker has a filter field");
|
|
235
|
+
assert.equal(dom.document.activeElement, filter);
|
|
236
|
+
|
|
237
|
+
dom.dispatch(
|
|
238
|
+
dom.window.document,
|
|
239
|
+
new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
|
|
240
|
+
);
|
|
241
|
+
assert.equal(dom.document.activeElement, dom.byLabel("Move to folder"));
|
|
242
|
+
});
|
|
243
|
+
|
|
162
244
|
it("refuses to open, and says why, when the selection spans accounts", () => {
|
|
163
245
|
const hint = "Select messages from one account to move them";
|
|
164
246
|
const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
|
|
@@ -212,6 +294,6 @@ describe("MoveToTrigger", () => {
|
|
|
212
294
|
dom.click(dom.byLabel("Move to folder"));
|
|
213
295
|
// No cached mailboxes and no network in the test harness: the picker
|
|
214
296
|
// shows its loading state rather than an empty list of destinations.
|
|
215
|
-
assert.match(dom
|
|
297
|
+
assert.match(pickerText(dom), /Loading folders/);
|
|
216
298
|
});
|
|
217
299
|
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
cn,
|
|
5
|
+
type FolderTreeNode,
|
|
6
|
+
FolderTreePicker,
|
|
7
|
+
PopoverMenuPortal,
|
|
8
|
+
} from "@remit/ui";
|
|
3
9
|
import { useQuery } from "@tanstack/react-query";
|
|
4
10
|
import { FolderInput } from "lucide-react";
|
|
5
11
|
import {
|
|
@@ -75,6 +81,8 @@ export const MoveToTrigger = ({
|
|
|
75
81
|
const [pickedId, setPickedId] = useState<string>();
|
|
76
82
|
const isDesktop = useIsDesktop();
|
|
77
83
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
84
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
85
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
78
86
|
const triggerLabel = label ?? "Move to folder";
|
|
79
87
|
const popoverId = useId();
|
|
80
88
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
@@ -119,6 +127,13 @@ export const MoveToTrigger = ({
|
|
|
119
127
|
const close = useCallback(() => {
|
|
120
128
|
setIsOpen(false);
|
|
121
129
|
setPickedId(undefined);
|
|
130
|
+
// The panel is about to unmount. Where the keyboard was inside it, hand
|
|
131
|
+
// focus back to the trigger rather than dropping it on the body; where it
|
|
132
|
+
// was elsewhere — a press that landed outside — leave it where it is.
|
|
133
|
+
const active = document.activeElement;
|
|
134
|
+
if (active && panelRef.current?.contains(active)) {
|
|
135
|
+
triggerRef.current?.focus();
|
|
136
|
+
}
|
|
122
137
|
}, []);
|
|
123
138
|
|
|
124
139
|
// Tapping a folder both picks it and opens it, so the move waits for a
|
|
@@ -134,12 +149,13 @@ export const MoveToTrigger = ({
|
|
|
134
149
|
useEffect(() => {
|
|
135
150
|
if (!isOpen || !isDesktop) return;
|
|
136
151
|
const handlePointer = (event: MouseEvent) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
152
|
+
const target = event.target as Node;
|
|
153
|
+
if (containerRef.current?.contains(target)) return;
|
|
154
|
+
// The picker renders through a portal into document.body, so it
|
|
155
|
+
// sits outside the trigger's subtree — count presses on it as
|
|
156
|
+
// inside, or picking a folder would close the picker first.
|
|
157
|
+
if (panelRef.current?.contains(target)) return;
|
|
158
|
+
close();
|
|
143
159
|
};
|
|
144
160
|
// The popover owns Escape while it is open (#732). The global triage
|
|
145
161
|
// layer listens on window and maps Escape to `back`, which closes the
|
|
@@ -159,11 +175,15 @@ export const MoveToTrigger = ({
|
|
|
159
175
|
};
|
|
160
176
|
}, [isOpen, isDesktop, close]);
|
|
161
177
|
|
|
178
|
+
const anchorRect = () =>
|
|
179
|
+
containerRef.current?.getBoundingClientRect() ?? null;
|
|
180
|
+
|
|
162
181
|
const isTriggerDisabled = disabled || !!disabledHint;
|
|
163
182
|
|
|
164
183
|
const TriggerButton = (
|
|
165
184
|
<button
|
|
166
185
|
type="button"
|
|
186
|
+
ref={triggerRef}
|
|
167
187
|
onClick={(event) => {
|
|
168
188
|
event.stopPropagation();
|
|
169
189
|
if (isTriggerDisabled) return;
|
|
@@ -204,6 +224,10 @@ export const MoveToTrigger = ({
|
|
|
204
224
|
onSelect={setPickedId}
|
|
205
225
|
onCreateFolder={createFolderIn}
|
|
206
226
|
onCancel={close}
|
|
227
|
+
// The desktop panel is portalled onto the body, so Tab from the
|
|
228
|
+
// trigger would otherwise walk on to the next toolbar button. The
|
|
229
|
+
// drawer runs its own focus and must not raise a phone keyboard.
|
|
230
|
+
autoFocusFilter={isDesktop}
|
|
207
231
|
labels={{
|
|
208
232
|
filterPlaceholder: t("move_picker_filter_placeholder", {
|
|
209
233
|
defaultValue: "Filter folders…",
|
|
@@ -260,18 +284,27 @@ export const MoveToTrigger = ({
|
|
|
260
284
|
return (
|
|
261
285
|
<div ref={containerRef} className="relative inline-block">
|
|
262
286
|
{TriggerButton}
|
|
263
|
-
{
|
|
287
|
+
{/* Portalled + fixed-positioned: an in-place absolute popover is
|
|
288
|
+
clipped by the reading pane's overflow-hidden shell and ends up
|
|
289
|
+
painted underneath the thread list (#601). */}
|
|
290
|
+
<PopoverMenuPortal
|
|
291
|
+
open={isOpen}
|
|
292
|
+
align="end"
|
|
293
|
+
panelRef={panelRef}
|
|
294
|
+
getAnchor={anchorRect}
|
|
295
|
+
>
|
|
264
296
|
<div
|
|
265
297
|
id={popoverId}
|
|
298
|
+
ref={panelRef}
|
|
266
299
|
className={cn(
|
|
267
|
-
"
|
|
300
|
+
"w-72 max-h-96 flex flex-col",
|
|
268
301
|
"bg-surface border border-line rounded-md shadow-lg",
|
|
269
302
|
)}
|
|
270
303
|
>
|
|
271
304
|
{pickerBody}
|
|
272
305
|
{confirmBar}
|
|
273
306
|
</div>
|
|
274
|
-
|
|
307
|
+
</PopoverMenuPortal>
|
|
275
308
|
</div>
|
|
276
309
|
);
|
|
277
310
|
};
|