@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.207",
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.queryAll("[role=treeitem]").map((row) => row.textContent ?? "");
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.byLabel("Move to Work"));
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.byLabel("Move to Work"));
152
+ dom.click(pickerByLabel(dom, "Move to Work"));
121
153
  assert.deepEqual(moved, []);
122
154
 
123
- dom.click(dom.byText("button", "Move to Work"));
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.byLabel("Move to Work"));
135
- dom.click(dom.byLabel("Move to Clients"));
166
+ dom.click(pickerByLabel(dom, "Move to Work"));
167
+ dom.click(pickerByLabel(dom, "Move to Clients"));
136
168
 
137
- assert.match(dom.text(), /Move to Clients/);
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.text(), /Loading folders/);
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 { Button, cn, type FolderTreeNode, FolderTreePicker } from "@remit/ui";
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
- if (
138
- containerRef.current &&
139
- !containerRef.current.contains(event.target as Node)
140
- ) {
141
- close();
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
- {isOpen && (
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
- "absolute right-0 mt-1 z-50 w-72 max-h-96 flex flex-col",
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
  };