@remit/web-client 0.0.101 → 0.0.102

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.101",
3
+ "version": "0.0.102",
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": {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The move-to-folder picker. It only fetches folders once it is opened, it
3
- * marks the folder the messages are already in rather than offering it as a
4
- * destination, and on desktop Escape or a click outside puts it away.
3
+ * opens on the account's top level with nested folders behind the one holding
4
+ * them, it marks the folder the messages are already in rather than offering it
5
+ * as a destination, and on desktop Escape or a click outside puts it away.
5
6
  */
6
7
 
7
8
  import assert from "node:assert/strict";
@@ -54,19 +55,23 @@ const mount = (
54
55
  const FOLDERS = [
55
56
  makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
56
57
  makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" }),
58
+ makeMailbox({ mailboxId: "mbx-clients", fullPath: "Work/Clients" }),
57
59
  makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
58
60
  ];
59
61
 
62
+ const rowText = (dom: DomHarness): string[] =>
63
+ dom.queryAll("[role=treeitem]").map((row) => row.textContent ?? "");
64
+
60
65
  describe("MoveToTrigger", () => {
61
66
  it("reports itself as collapsed until it is opened", () => {
62
67
  const dom = mount({ mailboxes: FOLDERS });
63
68
  const trigger = dom.byLabel("Move to folder");
64
69
  assert.equal(trigger.getAttribute("aria-expanded"), "false");
65
70
  assert.equal(trigger.getAttribute("aria-controls"), null);
66
- assert.equal(dom.query('[role="listbox"], input'), null);
71
+ assert.equal(dom.query('[role="tree"], input'), null);
67
72
  });
68
73
 
69
- it("opens a listbox of destinations and marks the folder we are in", () => {
74
+ it("opens the top level and marks the folder we are in", () => {
70
75
  const dom = mount({ mailboxes: FOLDERS });
71
76
  dom.click(dom.byLabel("Move to folder"));
72
77
 
@@ -74,9 +79,7 @@ describe("MoveToTrigger", () => {
74
79
  dom.byLabel("Move to folder").getAttribute("aria-expanded"),
75
80
  "true",
76
81
  );
77
- const labels = dom
78
- .queryAll("[role=option]")
79
- .map((option) => option.textContent ?? "");
82
+ const labels = rowText(dom);
80
83
  assert.ok(labels.some((label) => label.includes("Work")));
81
84
  assert.ok(labels.some((label) => label.includes("Receipts")));
82
85
 
@@ -86,15 +89,32 @@ describe("MoveToTrigger", () => {
86
89
  assert.ok(current, "the source folder is marked as the current one");
87
90
  });
88
91
 
89
- it("moves to the folder the user picks and closes itself", () => {
92
+ it("keeps a nested folder behind the folder that holds it", () => {
93
+ const dom = mount({ mailboxes: FOLDERS });
94
+ dom.click(dom.byLabel("Move to folder"));
95
+ assert.equal(
96
+ rowText(dom).some((label) => label.includes("Clients")),
97
+ false,
98
+ );
99
+
100
+ dom.click(dom.byLabel("Move to Work"));
101
+ assert.ok(rowText(dom).some((label) => label.includes("Clients")));
102
+ });
103
+
104
+ it("waits for the move to be confirmed, then closes itself", () => {
90
105
  const moved: string[] = [];
91
106
  const dom = mount({
92
107
  mailboxes: FOLDERS,
93
108
  onMove: (id) => moved.push(id),
94
109
  });
95
110
  dom.click(dom.byLabel("Move to folder"));
96
- dom.click(dom.byText("[role=option]", "Work"));
97
111
 
112
+ // Picking a folder also opens it, so the move is a separate press —
113
+ // otherwise the first tap would fire before anything nested was reachable.
114
+ dom.click(dom.byLabel("Move to Work"));
115
+ assert.deepEqual(moved, []);
116
+
117
+ dom.click(dom.byText("button", "Move to Work"));
98
118
  assert.deepEqual(moved, ["mbx-work"]);
99
119
  assert.equal(
100
120
  dom.byLabel("Move to folder").getAttribute("aria-expanded"),
@@ -102,6 +122,15 @@ describe("MoveToTrigger", () => {
102
122
  );
103
123
  });
104
124
 
125
+ it("names the destination on the button that runs the move", () => {
126
+ const dom = mount({ mailboxes: FOLDERS });
127
+ dom.click(dom.byLabel("Move to folder"));
128
+ dom.click(dom.byLabel("Move to Work"));
129
+ dom.click(dom.byLabel("Move to Clients"));
130
+
131
+ assert.match(dom.text(), /Move to Clients/);
132
+ });
133
+
105
134
  it("closes on Escape and on a click outside it", () => {
106
135
  const dom = mount({ mailboxes: FOLDERS });
107
136
  const isOpen = () =>
@@ -144,7 +173,7 @@ describe("MoveToTrigger", () => {
144
173
 
145
174
  it("asks for the folder list only once the picker is opened", () => {
146
175
  const dom = mount();
147
- assert.equal(dom.queryAll("[role=option]").length, 0);
176
+ assert.equal(dom.queryAll("[role=treeitem]").length, 0);
148
177
  dom.click(dom.byLabel("Move to folder"));
149
178
  // No cached mailboxes and no network in the test harness: the picker
150
179
  // shows its loading state rather than an empty list of destinations.
@@ -1,5 +1,5 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { type MoveMailboxOption, MoveMailboxPicker } from "@remit/ui";
2
+ import { Button, type FolderTreeNode, FolderTreePicker } from "@remit/ui";
3
3
  import { useQuery } from "@tanstack/react-query";
4
4
  import { FolderInput } from "lucide-react";
5
5
  import {
@@ -17,7 +17,7 @@ import { useFolderAppointments } from "@/hooks/useArchiveMailbox";
17
17
  import { useCreateMailbox } from "@/hooks/useCreateMailbox";
18
18
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
19
19
  import { useIsDesktop } from "@/hooks/useMediaQuery";
20
- import { buildMoveOptions } from "@/lib/move-options";
20
+ import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
21
21
  import { cn } from "@/lib/utils";
22
22
 
23
23
  interface MoveToTriggerProps {
@@ -66,6 +66,7 @@ export const MoveToTrigger = ({
66
66
  label,
67
67
  }: MoveToTriggerProps) => {
68
68
  const [isOpen, setIsOpen] = useState(false);
69
+ const [pickedId, setPickedId] = useState<string>();
69
70
  const isDesktop = useIsDesktop();
70
71
  const containerRef = useRef<HTMLDivElement>(null);
71
72
  const triggerLabel = label ?? "Move to folder";
@@ -88,9 +89,9 @@ export const MoveToTrigger = ({
88
89
  enabled: isOpen,
89
90
  });
90
91
  const folderAppointments = useFolderAppointments(accountId);
91
- const { createFolder } = useCreateMailbox(accountId);
92
+ const { createFolderIn } = useCreateMailbox(accountId);
92
93
 
93
- const options = useMemo<MoveMailboxOption[]>(
94
+ const options = useMemo<FolderTreeNode[]>(
94
95
  () =>
95
96
  buildMoveOptions({
96
97
  mailboxes: mailboxesResponse?.items ?? [],
@@ -105,22 +106,23 @@ export const MoveToTrigger = ({
105
106
  translator,
106
107
  ],
107
108
  );
109
+ const delimiter = folderDelimiter(mailboxesResponse?.items ?? []);
108
110
 
109
- const handleSelect = useCallback(
110
- (destinationMailboxId: string) => {
111
- setIsOpen(false);
112
- onMove(destinationMailboxId);
113
- },
114
- [onMove],
115
- );
111
+ const picked = options.find((option) => option.id === pickedId);
116
112
 
117
- const handleCreateFolder = useCallback(
118
- async (name: string, signal?: AbortSignal): Promise<MoveMailboxOption> => {
119
- const folder = await createFolder(name, signal);
120
- return { id: folder.id, label: folder.label };
121
- },
122
- [createFolder],
123
- );
113
+ const close = useCallback(() => {
114
+ setIsOpen(false);
115
+ setPickedId(undefined);
116
+ }, []);
117
+
118
+ // Tapping a folder both picks it and opens it, so the move waits for a
119
+ // confirmation — otherwise the first tap would fire before the user could
120
+ // reach anything nested inside it.
121
+ const handleMove = useCallback(() => {
122
+ if (!picked) return;
123
+ close();
124
+ onMove(picked.id);
125
+ }, [picked, close, onMove]);
124
126
 
125
127
  // Desktop popover: dismiss on outside click + Escape.
126
128
  useEffect(() => {
@@ -130,11 +132,11 @@ export const MoveToTrigger = ({
130
132
  containerRef.current &&
131
133
  !containerRef.current.contains(event.target as Node)
132
134
  ) {
133
- setIsOpen(false);
135
+ close();
134
136
  }
135
137
  };
136
138
  const handleKey = (event: KeyboardEvent) => {
137
- if (event.key === "Escape") setIsOpen(false);
139
+ if (event.key === "Escape") close();
138
140
  };
139
141
  document.addEventListener("mousedown", handlePointer);
140
142
  document.addEventListener("keydown", handleKey);
@@ -142,7 +144,7 @@ export const MoveToTrigger = ({
142
144
  document.removeEventListener("mousedown", handlePointer);
143
145
  document.removeEventListener("keydown", handleKey);
144
146
  };
145
- }, [isOpen, isDesktop]);
147
+ }, [isOpen, isDesktop, close]);
146
148
 
147
149
  const isTriggerDisabled = disabled || !!disabledHint;
148
150
 
@@ -156,10 +158,10 @@ export const MoveToTrigger = ({
156
158
  }}
157
159
  aria-label={triggerLabel}
158
160
  // Mobile opens a vaul Drawer (modal dialog), desktop opens a
159
- // non-modal popover whose only content is the listbox of
160
- // destinations. Reflect each surface accurately so screen readers
161
- // announce the right structure.
162
- aria-haspopup={isDesktop ? "listbox" : "dialog"}
161
+ // non-modal popover whose only content is the folder tree. Reflect
162
+ // each surface accurately so screen readers announce the right
163
+ // structure.
164
+ aria-haspopup={isDesktop ? "tree" : "dialog"}
163
165
  aria-expanded={isOpen}
164
166
  aria-controls={isOpen ? popoverId : undefined}
165
167
  title={disabledHint}
@@ -182,33 +184,44 @@ export const MoveToTrigger = ({
182
184
  />
183
185
  </div>
184
186
  ) : (
185
- <MoveMailboxPicker
186
- mailboxes={options}
187
- onSelect={handleSelect}
188
- onCreateFolder={handleCreateFolder}
189
- onCancel={() => setIsOpen(false)}
190
- autoFocus={!isDesktop}
187
+ <FolderTreePicker
188
+ folders={options}
189
+ selectedId={pickedId}
190
+ delimiter={delimiter}
191
+ onSelect={setPickedId}
192
+ onCreateFolder={createFolderIn}
193
+ onCancel={close}
191
194
  labels={{
192
- createLabel: (folderName) => `Create "${folderName}"`,
193
- searchPlaceholder: t("move_picker_placeholder", {
194
- defaultValue: "Move to…",
195
+ filterPlaceholder: t("move_picker_filter_placeholder", {
196
+ defaultValue: "Filter folders…",
195
197
  }),
196
- searchAriaLabel: t("move_picker_filter_label", {
198
+ filterAriaLabel: t("move_picker_filter_label", {
197
199
  defaultValue: "Filter folders",
198
200
  }),
199
- optionLabel: (folderLabel) => `Move to ${folderLabel}`,
200
- currentSuffix: "(current folder)",
201
- currentTag: "current",
202
- emptyMessage: (query) => `No folders match "${query}"`,
203
201
  }}
204
202
  />
205
203
  );
206
204
 
205
+ const confirmBar = picked && (
206
+ <div className="shrink-0 border-t border-line p-2">
207
+ <Button
208
+ variant="primary"
209
+ onClick={handleMove}
210
+ className="h-11 w-full font-semibold"
211
+ >
212
+ {`Move to ${picked.label}`}
213
+ </Button>
214
+ </div>
215
+ );
216
+
207
217
  if (!isDesktop) {
208
218
  return (
209
219
  <>
210
220
  {TriggerButton}
211
- <Drawer.Root open={isOpen} onOpenChange={setIsOpen}>
221
+ <Drawer.Root
222
+ open={isOpen}
223
+ onOpenChange={(next) => (next ? setIsOpen(true) : close())}
224
+ >
212
225
  <Drawer.Portal>
213
226
  <Drawer.Overlay className="fixed inset-0 z-40 bg-black/40" />
214
227
  <Drawer.Content
@@ -220,7 +233,10 @@ export const MoveToTrigger = ({
220
233
  <Drawer.Title className="px-4 py-2 text-base font-semibold border-b border-line">
221
234
  Move to folder
222
235
  </Drawer.Title>
223
- <div className="flex-1 overflow-hidden">{pickerBody}</div>
236
+ <div className="flex min-h-0 flex-1 overflow-hidden">
237
+ {pickerBody}
238
+ </div>
239
+ {confirmBar}
224
240
  </Drawer.Content>
225
241
  </Drawer.Portal>
226
242
  </Drawer.Root>
@@ -240,6 +256,7 @@ export const MoveToTrigger = ({
240
256
  )}
241
257
  >
242
258
  {pickerBody}
259
+ {confirmBar}
243
260
  </div>
244
261
  )}
245
262
  </div>
@@ -7,10 +7,10 @@ import {
7
7
  derivePropertyClauses,
8
8
  deriveSenderClauses,
9
9
  dominantSender,
10
+ type FolderTreeNode,
10
11
  type MatchCount,
11
12
  type MatchDoor,
12
13
  type MatchMode,
13
- type MoveMailboxOption,
14
14
  type RuleClause,
15
15
  type RunState,
16
16
  type SearchConversion,
@@ -50,7 +50,7 @@ import { useSelectedSubjects } from "@/hooks/useSelectedSubjects";
50
50
  import type { BulkActionProgress, BulkRunOutcome } from "@/lib/bulk-actions";
51
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
52
  import { useMailContext } from "@/lib/mail-context";
53
- import { buildMoveOptions } from "@/lib/move-options";
53
+ import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
54
54
  import {
55
55
  buildWizardDraft,
56
56
  canBackApplyDraft,
@@ -388,7 +388,7 @@ function SelectionWizardSession({
388
388
  });
389
389
  const folderAppointments = useFolderAppointments(accountId);
390
390
  const translator = useFolderLabelTranslator();
391
- const mailboxes = useMemo<MoveMailboxOption[]>(
391
+ const mailboxes = useMemo<FolderTreeNode[]>(
392
392
  () =>
393
393
  buildMoveOptions({
394
394
  mailboxes: mailboxesData?.items ?? [],
@@ -398,7 +398,7 @@ function SelectionWizardSession({
398
398
  }),
399
399
  [mailboxesData?.items, folderAppointments, mailboxId, translator],
400
400
  );
401
- const { createFolder } = useCreateMailbox(accountId);
401
+ const { createFolderIn } = useCreateMailbox(accountId);
402
402
 
403
403
  const folderLabel = mailboxes.find(
404
404
  (mailbox) => mailbox.id === draft.moveMailboxId,
@@ -946,11 +946,12 @@ function SelectionWizardSession({
946
946
  sample: { ...sample, label: "What this matches" },
947
947
  }}
948
948
  folder={{
949
- mailboxes,
949
+ folders: mailboxes,
950
950
  mailboxId: named.moveMailboxId,
951
+ delimiter: folderDelimiter(mailboxesData?.items ?? []),
951
952
  onSelect: (moveMailboxId) =>
952
953
  setDraft((held) => ({ ...held, moveMailboxId })),
953
- onCreateFolder: accountId ? createFolder : undefined,
954
+ onCreateFolder: accountId ? createFolderIn : undefined,
954
955
  restriction: folderRestriction,
955
956
  }}
956
957
  rule={{
@@ -1,6 +1,6 @@
1
1
  import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
2
  import {
3
- type MoveMailboxOption,
3
+ type FolderTreeNode,
4
4
  RescueBanner,
5
5
  type RescueCandidate,
6
6
  RescueFromSpamFlow,
@@ -19,7 +19,7 @@ import {
19
19
  useInboxMailbox,
20
20
  } from "@/hooks/useArchiveMailbox";
21
21
  import { useFolderLabelTranslator } from "@/hooks/useFolderLabelTranslator";
22
- import { buildMoveOptions } from "@/lib/move-options";
22
+ import { buildMoveOptions, folderDelimiter } from "@/lib/move-options";
23
23
  import {
24
24
  recordRescueCandidatesSurfaced,
25
25
  recordRescueCommitted,
@@ -60,7 +60,7 @@ export function SpamRescue({
60
60
  staleTime: Infinity,
61
61
  });
62
62
 
63
- const folders = useMemo<MoveMailboxOption[]>(
63
+ const folders = useMemo<FolderTreeNode[]>(
64
64
  () =>
65
65
  buildMoveOptions({
66
66
  mailboxes: mailboxesResponse?.items ?? [],
@@ -117,6 +117,7 @@ export function SpamRescue({
117
117
  candidates={candidates}
118
118
  defaultDestinationId={defaultDestinationId}
119
119
  availableFolders={folders}
120
+ delimiter={folderDelimiter(mailboxesResponse?.items ?? [])}
120
121
  onConfirmMove={handleConfirmMove}
121
122
  onCancel={() => setOpen(false)}
122
123
  />
@@ -83,23 +83,16 @@ export function DeleteFolderDialog({
83
83
  onClose();
84
84
  }, [cancel, onClose]);
85
85
 
86
- // `buildMoveOptions` carries the label and the searchable path but not the
87
- // provider path the tree nests by, so the path is joined back on by id here.
88
- const destinations = useMemo<FolderTreeNode[]>(() => {
89
- const pathById = new Map(
90
- mailboxes.map((mailbox) => [mailbox.mailboxId, mailbox.fullPath]),
91
- );
92
- return buildMoveOptions({
93
- mailboxes,
94
- folderAppointments: appointments,
95
- excludeMailboxId: folder.mailboxId,
96
- translator,
97
- }).map((option) => ({
98
- id: option.id,
99
- label: option.label,
100
- path: pathById.get(option.id) ?? option.label,
101
- }));
102
- }, [mailboxes, appointments, folder.mailboxId, translator]);
86
+ const destinations = useMemo<FolderTreeNode[]>(
87
+ () =>
88
+ buildMoveOptions({
89
+ mailboxes,
90
+ folderAppointments: appointments,
91
+ excludeMailboxId: folder.mailboxId,
92
+ translator,
93
+ }),
94
+ [mailboxes, appointments, folder.mailboxId, translator],
95
+ );
103
96
 
104
97
  const name = useMemo(
105
98
  () =>
@@ -6,7 +6,7 @@ import type {
6
6
  RemitImapFolderAppointment,
7
7
  RemitImapMailboxResponse,
8
8
  } from "@remit/api-http-client/types.gen.ts";
9
- import { buildMoveOptions } from "./move-options.js";
9
+ import { buildMoveOptions, folderDelimiter } from "./move-options.js";
10
10
 
11
11
  const englishBundle = JSON.parse(
12
12
  readFileSync(
@@ -100,16 +100,15 @@ describe("buildMoveOptions", () => {
100
100
  assert.equal(labelOf(options, "mb-receipts"), "Receipts");
101
101
  });
102
102
 
103
- test("the full provider path stays searchable", () => {
103
+ test("a role label keeps the provider path it nests under", () => {
104
104
  const options = buildMoveOptions({
105
105
  mailboxes: applePaths,
106
106
  folderAppointments: appleAppointments,
107
107
  translator: translate,
108
108
  });
109
- assert.equal(
110
- options.find((option) => option.id === "mb-trash")?.searchValue,
111
- "INBOX/Deleted Messages",
112
- );
109
+ const trash = options.find((option) => option.id === "mb-trash");
110
+ assert.equal(trash?.label, "Trash");
111
+ assert.equal(trash?.path, "INBOX/Deleted Messages");
113
112
  });
114
113
 
115
114
  test("the current mailbox is marked and nothing else is", () => {
@@ -138,3 +137,18 @@ describe("buildMoveOptions", () => {
138
137
  );
139
138
  });
140
139
  });
140
+
141
+ describe("folderDelimiter", () => {
142
+ test("takes the account's own hierarchy separator", () => {
143
+ assert.equal(
144
+ folderDelimiter([
145
+ make({ mailboxId: "mb-1", fullPath: "INBOX", hierarchyDelimiter: "." }),
146
+ ]),
147
+ ".",
148
+ );
149
+ });
150
+
151
+ test("falls back to a slash when there are no mailboxes yet", () => {
152
+ assert.equal(folderDelimiter([]), "/");
153
+ });
154
+ });
@@ -2,7 +2,7 @@ import type {
2
2
  RemitImapFolderAppointment,
3
3
  RemitImapMailboxResponse,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
- import type { MoveMailboxOption } from "@remit/ui";
5
+ import type { FolderTreeNode } from "@remit/ui";
6
6
  import { excludeFolder } from "./delete-folder.js";
7
7
  import { buildMailboxRoleMap, labelForMailbox } from "./folder-roles.js";
8
8
  import { buildMoveTargets } from "./move-targets.js";
@@ -19,11 +19,20 @@ interface MoveOptionsInput {
19
19
  translator?: Translator;
20
20
  }
21
21
 
22
+ /**
23
+ * The account's hierarchy separator, which the tree splits paths on. Every
24
+ * mailbox of an account reports the same one, so the first is the account's.
25
+ */
26
+ export const folderDelimiter = (
27
+ mailboxes: readonly RemitImapMailboxResponse[],
28
+ ): string => mailboxes[0]?.hierarchyDelimiter ?? "/";
29
+
22
30
  /**
23
31
  * The single shaping of a mailbox list into Move-to picker options. Labels
24
32
  * follow the account's role appointments (RFC 032, #976) and any
25
33
  * `displayNameOverride`, so a picker reads `Inbox`/`Trash` rather than the
26
- * provider's leaf; the full path stays searchable.
34
+ * provider's leaf, while the provider path it nests and filters under stays
35
+ * untouched.
27
36
  */
28
37
  export const buildMoveOptions = ({
29
38
  mailboxes,
@@ -31,7 +40,7 @@ export const buildMoveOptions = ({
31
40
  currentMailboxId,
32
41
  excludeMailboxId,
33
42
  translator,
34
- }: MoveOptionsInput): MoveMailboxOption[] => {
43
+ }: MoveOptionsInput): FolderTreeNode[] => {
35
44
  const targets = buildMoveTargets(mailboxes, folderAppointments);
36
45
  const roleMap = buildMailboxRoleMap(folderAppointments);
37
46
  const destinations = excludeMailboxId
@@ -40,7 +49,7 @@ export const buildMoveOptions = ({
40
49
  return destinations.map((mailbox) => ({
41
50
  id: mailbox.mailboxId,
42
51
  label: labelForMailbox(mailbox, roleMap.get(mailbox.mailboxId), translator),
43
- searchValue: mailbox.fullPath,
52
+ path: mailbox.fullPath,
44
53
  isCurrent: mailbox.mailboxId === currentMailboxId,
45
54
  }));
46
55
  };