@remit/ui 0.0.83 → 0.0.84

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/ui",
3
- "version": "0.0.83",
3
+ "version": "0.0.84",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,10 +1,10 @@
1
1
  /**
2
- * The move-to "New folder…" affordance: the option shows only when
3
- * `onCreateFolder` is wired; choosing it reveals a name field, and a resolved
4
- * create picks the new folder as the destination. Mounted against jsdom for the
5
- * select interaction, the inline field state, and the async resolve. React is
6
- * imported after the jsdom globals are installed so the controlled value tracker
7
- * binds to jsdom's prototypes.
2
+ * The move-to destination as the browsable folder tree: what it says before a
3
+ * folder is chosen, that a nested folder is told apart from a same-named
4
+ * sibling, and that a folder can be made inside another one from here. Mounted
5
+ * against jsdom for the tree interaction, the create form state and the async
6
+ * resolve. React is imported after the jsdom globals are installed so the
7
+ * controlled value tracker binds to jsdom's prototypes.
8
8
  */
9
9
  import assert from "node:assert/strict";
10
10
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
@@ -12,14 +12,26 @@ import type { JSDOM } from "jsdom";
12
12
  import type {
13
13
  act as reactAct,
14
14
  createElement as reactCreateElement,
15
+ useState as reactUseState,
15
16
  } from "react";
16
17
  import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
17
- import type { FilterRule, FolderOption, PreviewCount } from "./filter-rule.js";
18
+ import type { FilterRule, PreviewCount } from "./filter-rule.js";
18
19
  import type { FilterRuleEditor as FilterRuleEditorType } from "./filter-rule-editor.js";
20
+ import type { FolderTreeNode } from "./folder-tree-picker.js";
19
21
 
20
- const folders: FolderOption[] = [
21
- { id: "mbx-inbox", label: "Inbox" },
22
- { id: "mbx-archive", label: "Archive" },
22
+ // `Prullenbak` labelled Trash is the account's own naming; two folders named
23
+ // Receipts at different depths are what a flat list of leaf names cannot tell
24
+ // apart.
25
+ const folders: FolderTreeNode[] = [
26
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
27
+ { id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
28
+ { id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
29
+ { id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
30
+ {
31
+ id: "mbx-travel-receipts",
32
+ label: "Receipts",
33
+ path: "INBOX/Travel/Receipts",
34
+ },
23
35
  ];
24
36
 
25
37
  const rule: FilterRule = {
@@ -35,6 +47,7 @@ let container: HTMLElement;
35
47
  let root: Root;
36
48
  let act: typeof reactAct;
37
49
  let createElement: typeof reactCreateElement;
50
+ let useState: typeof reactUseState;
38
51
  let createRoot: typeof reactCreateRoot;
39
52
  let FilterRuleEditor: typeof FilterRuleEditorType;
40
53
 
@@ -56,10 +69,15 @@ before(async () => {
56
69
  (
57
70
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
58
71
  ).IS_REACT_ACT_ENVIRONMENT = true;
72
+ Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
73
+ configurable: true,
74
+ value: () => undefined,
75
+ });
59
76
 
60
77
  const react = await import("react");
61
78
  act = react.act;
62
79
  createElement = react.createElement;
80
+ useState = react.useState;
63
81
  ({ createRoot } = await import("react-dom/client"));
64
82
  ({ FilterRuleEditor } = await import("./filter-rule-editor.js"));
65
83
  });
@@ -76,174 +94,262 @@ beforeEach(() => {
76
94
  root = createRoot(container);
77
95
  });
78
96
 
79
- afterEach(() => {
80
- act(() => {
97
+ afterEach(async () => {
98
+ await act(async () => {
81
99
  root.unmount();
82
100
  });
83
101
  });
84
102
 
85
- function destinationSelect(): HTMLSelectElement {
86
- return container.querySelector(
87
- 'select[aria-label="Destination folder"]',
88
- ) as HTMLSelectElement;
89
- }
90
-
91
- function createOption(): HTMLOptionElement | undefined {
92
- return Array.from(destinationSelect().querySelectorAll("option")).find(
93
- (option) => option.textContent?.includes("New folder"),
94
- );
103
+ interface MountOptions {
104
+ /** The destination the rule already carries when the editor opens. */
105
+ initialDestination?: string;
106
+ onCreateFolder?: (
107
+ name: string,
108
+ parentPath: string,
109
+ signal?: AbortSignal,
110
+ ) => Promise<FolderTreeNode>;
111
+ onChangeMove?: (id: string) => void;
95
112
  }
96
113
 
97
- function chooseCreateOption() {
98
- const select = destinationSelect();
99
- const value = createOption()?.value ?? "";
100
- const setter = Object.getOwnPropertyDescriptor(
101
- dom.window.HTMLSelectElement.prototype,
102
- "value",
103
- )?.set;
104
- setter?.call(select, value);
105
- act(() => {
106
- select.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
114
+ /** Holds the destination the way the app does, so a pick shows on screen. */
115
+ const mount = async ({
116
+ initialDestination,
117
+ onCreateFolder,
118
+ onChangeMove,
119
+ }: MountOptions = {}) => {
120
+ const Controlled = () => {
121
+ const [moveMailboxId, setMoveMailboxId] = useState<string | undefined>(
122
+ initialDestination,
123
+ );
124
+ return createElement(FilterRuleEditor, {
125
+ rule: { ...rule, moveMailboxId },
126
+ folders,
127
+ preview,
128
+ onChangeMove: (id: string) => {
129
+ setMoveMailboxId(id || undefined);
130
+ onChangeMove?.(id);
131
+ },
132
+ onCreateFolder,
133
+ onCommit: () => {},
134
+ onCancel: () => {},
135
+ });
136
+ };
137
+ await act(async () => {
138
+ root.render(createElement(Controlled));
107
139
  });
108
- }
140
+ };
109
141
 
110
- function setInput(el: HTMLInputElement, value: string) {
111
- const setter = Object.getOwnPropertyDescriptor(
112
- dom.window.HTMLInputElement.prototype,
113
- "value",
114
- )?.set;
115
- setter?.call(el, value);
116
- act(() => {
117
- el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
142
+ const click = async (element: Element | null | undefined) => {
143
+ if (!(element instanceof dom.window.HTMLElement))
144
+ assert.fail("control not rendered");
145
+ await act(async () => {
146
+ element.click();
118
147
  });
119
- }
148
+ };
149
+
150
+ const byAriaLabel = (label: string): HTMLElement | null =>
151
+ container.querySelector(`[aria-label="${label}"]`);
120
152
 
121
- function button(text: string): HTMLButtonElement | undefined {
122
- return Array.from(container.querySelectorAll("button")).find(
153
+ const byText = (text: string): HTMLButtonElement | undefined =>
154
+ Array.from(container.querySelectorAll("button")).find(
123
155
  (candidate) => candidate.textContent?.trim() === text,
124
156
  );
125
- }
126
157
 
127
- function mount(overrides: {
128
- onCreateFolder?: (name: string) => Promise<FolderOption>;
129
- onChangeMove?: (id: string) => void;
130
- }) {
131
- act(() => {
132
- root.render(
133
- createElement(FilterRuleEditor, {
134
- rule,
135
- folders,
136
- preview,
137
- onCommit: () => {},
138
- onCancel: () => {},
139
- ...overrides,
140
- }),
141
- );
158
+ const rows = (): HTMLElement[] =>
159
+ Array.from(container.querySelectorAll('[role="treeitem"]'));
160
+
161
+ const receiptRows = (): HTMLElement[] =>
162
+ rows().filter((row) => row.getAttribute("aria-label") === "Move to Receipts");
163
+
164
+ const rowLabels = (): string[] =>
165
+ rows().map((row) => row.getAttribute("aria-label") ?? "");
166
+
167
+ const openTree = async () => {
168
+ await click(byText("Choose a folder"));
169
+ };
170
+
171
+ const openFolder = async (label: string) => {
172
+ await click(byAriaLabel(`Move to ${label}`));
173
+ };
174
+
175
+ const nameField = (): HTMLInputElement | null => {
176
+ const label = Array.from(container.querySelectorAll("label")).find(
177
+ (node) => node.textContent?.trim() === "Folder name",
178
+ );
179
+ const id = label?.getAttribute("for");
180
+ return id
181
+ ? (container.querySelector(`input[id="${id}"]`) as HTMLInputElement | null)
182
+ : null;
183
+ };
184
+
185
+ const typeName = async (value: string) => {
186
+ const input = nameField();
187
+ assert.ok(input, "the folder name field is on screen");
188
+ await act(async () => {
189
+ Object.getOwnPropertyDescriptor(
190
+ dom.window.HTMLInputElement.prototype,
191
+ "value",
192
+ )?.set?.call(input, value);
193
+ input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
142
194
  });
143
- }
195
+ };
144
196
 
145
- describe("FilterRuleEditor new-folder option", () => {
146
- it("does not offer the create option without onCreateFolder", () => {
147
- mount({});
148
- assert.equal(createOption(), undefined);
197
+ describe("FilterRuleEditor move destination", () => {
198
+ it("keeps the tree closed until a destination is asked for", async () => {
199
+ await mount();
200
+ assert.equal(rows().length, 0);
201
+ assert.match(container.textContent ?? "", /No folder yet/);
202
+ await openTree();
203
+ assert.ok(rows().length > 0, "the folder tree is on screen");
149
204
  });
150
205
 
151
- it("offers the create option when onCreateFolder is wired", () => {
152
- mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
153
- assert.ok(createOption(), "the + New folder… option is present");
206
+ it("names a folder as the account names it, not as the provider paths it", async () => {
207
+ await mount();
208
+ await openTree();
209
+ await openFolder("Inbox");
210
+ assert.ok(
211
+ rowLabels().includes("Move to Trash"),
212
+ "the renamed folder reads as Trash",
213
+ );
214
+ assert.ok(
215
+ !rowLabels().some((label) => label.includes("Prullenbak")),
216
+ "the provider leaf is never what the row reads as",
217
+ );
154
218
  });
155
219
 
156
- it("reveals the name field only after the option is chosen", () => {
157
- mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
158
- assert.equal(
159
- container.querySelector('input[aria-label="New folder name"]'),
160
- null,
220
+ it("tells a nested folder apart from its same-named sibling", async () => {
221
+ await mount();
222
+ await openTree();
223
+ await openFolder("Inbox");
224
+ // The nested Receipts is out of reach until Travel is opened, so the two
225
+ // same-named folders are never two identical entries in one list.
226
+ assert.equal(receiptRows().length, 1);
227
+ await openFolder("Travel");
228
+ const nested = receiptRows();
229
+ assert.equal(nested.length, 2);
230
+ assert.deepEqual(
231
+ nested.map((row) => row.getAttribute("aria-level")),
232
+ ["2", "3"],
161
233
  );
162
- chooseCreateOption();
234
+ await click(nested[1]);
163
235
  assert.ok(
164
- container.querySelector('input[aria-label="New folder name"]'),
165
- "the name field appears",
236
+ byText("Move matches to Inbox / Travel / Receipts"),
237
+ "the destination reads as its trail, not a bare leaf name",
166
238
  );
167
239
  });
168
240
 
169
- it("creates the folder and selects it as the destination", async () => {
170
- const moved: string[] = [];
171
- mount({
172
- onChangeMove: (id) => moved.push(id),
173
- onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
174
- });
175
- chooseCreateOption();
176
- const nameInput = container.querySelector(
177
- 'input[aria-label="New folder name"]',
178
- ) as HTMLInputElement;
179
- setInput(nameInput, "Receipts");
180
- await act(async () => {
181
- button("Create folder")?.click();
241
+ it("re-points the rule only on the confirmation, never on the way past", async () => {
242
+ const picked: string[] = [];
243
+ await mount({ onChangeMove: (id) => picked.push(id) });
244
+ await openTree();
245
+ await openFolder("Inbox");
246
+ await openFolder("Travel");
247
+ await click(receiptRows()[1]);
248
+ assert.deepEqual(picked, [], "browsing changes nothing");
249
+ await click(byText("Move matches to Inbox / Travel / Receipts"));
250
+ assert.deepEqual(picked, ["mbx-travel-receipts"]);
251
+ });
252
+
253
+ it("leaves the destination alone when the tree is cancelled", async () => {
254
+ const picked: string[] = [];
255
+ await mount({ onChangeMove: (id) => picked.push(id) });
256
+ await openTree();
257
+ await openFolder("Inbox");
258
+ await click(byAriaLabel("Move to Trash"));
259
+ await click(byText("Cancel"));
260
+ assert.deepEqual(picked, []);
261
+ assert.match(container.textContent ?? "", /No folder yet/);
262
+ });
263
+
264
+ it("opens on the branch holding the destination the rule already has", async () => {
265
+ await mount({ initialDestination: "mbx-travel-receipts" });
266
+ assert.match(container.textContent ?? "", /Inbox \/ Travel \/ Receipts/);
267
+ await openTree();
268
+ const selected = rows().filter(
269
+ (row) => row.getAttribute("aria-selected") === "true",
270
+ );
271
+ assert.deepEqual(
272
+ selected.map((row) => row.getAttribute("aria-level")),
273
+ ["3"],
274
+ );
275
+ });
276
+
277
+ it("drops the move action so a rule can apply a label alone", async () => {
278
+ const picked: string[] = [];
279
+ await mount({
280
+ initialDestination: "mbx-receipts",
281
+ onChangeMove: (id) => picked.push(id),
182
282
  });
183
- assert.deepEqual(moved, ["mbx-created"]);
283
+ await click(byText("Don't move matches"));
284
+ assert.deepEqual(picked, [""]);
184
285
  });
185
286
 
186
- it("keeps the name field open and surfaces the rejection message when create fails", async () => {
187
- const moved: string[] = [];
188
- mount({
189
- onChangeMove: (id) => moved.push(id),
190
- onCreateFolder: async () => {
191
- throw new Error("A folder with that name already exists.");
287
+ it("offers no create affordance without onCreateFolder", async () => {
288
+ await mount();
289
+ await openTree();
290
+ assert.equal(byAriaLabel("New folder"), null);
291
+ });
292
+
293
+ it("creates a folder inside another and makes it the destination", async () => {
294
+ const created: { name: string; parentPath: string }[] = [];
295
+ const picked: string[] = [];
296
+ await mount({
297
+ onChangeMove: (id) => picked.push(id),
298
+ onCreateFolder: async (name, parentPath) => {
299
+ created.push({ name, parentPath });
300
+ return {
301
+ id: "mbx-created",
302
+ label: name,
303
+ path: `${parentPath}/${name}`,
304
+ };
192
305
  },
193
306
  });
194
- chooseCreateOption();
195
- const nameInput = container.querySelector(
196
- 'input[aria-label="New folder name"]',
197
- ) as HTMLInputElement;
198
- setInput(nameInput, "Archive");
199
- await act(async () => {
200
- button("Create folder")?.click();
307
+ await openTree();
308
+ await openFolder("Inbox");
309
+ await openFolder("Travel");
310
+ await click(byAriaLabel("New folder inside Travel"));
311
+ await typeName("Car hire");
312
+ await click(byText("Create folder"));
313
+ assert.deepEqual(created, [
314
+ { name: "Car hire", parentPath: "INBOX/Travel" },
315
+ ]);
316
+ await click(byText("Move matches to Inbox / Travel / Car hire"));
317
+ assert.deepEqual(picked, ["mbx-created"]);
318
+ });
319
+
320
+ it("offers a created folder before the caller's folder list refetches", async () => {
321
+ await mount({
322
+ onCreateFolder: async (name, parentPath) => ({
323
+ id: "mbx-created",
324
+ label: name,
325
+ path: parentPath ? `${parentPath}/${name}` : name,
326
+ }),
201
327
  });
202
- assert.deepEqual(moved, []);
203
- assert.ok(
204
- container.querySelector('input[aria-label="New folder name"]'),
205
- "the name field stays open",
206
- );
207
- assert.match(
208
- container.querySelector('[role="alert"]')?.textContent ?? "",
209
- /already exists/,
210
- );
328
+ await openTree();
329
+ await click(byAriaLabel("New folder"));
330
+ await typeName("Receipts 2026");
331
+ await click(byText("Create folder"));
332
+ await click(byText("Move matches to Receipts 2026"));
333
+ assert.match(container.textContent ?? "", /Receipts 2026/);
211
334
  });
212
335
 
213
- it("falls back to the generic message for a non-Error rejection", async () => {
214
- mount({
336
+ it("states a failed create where it happened and binds no destination", async () => {
337
+ const picked: string[] = [];
338
+ await mount({
339
+ onChangeMove: (id) => picked.push(id),
215
340
  onCreateFolder: async () => {
216
- throw "opaque";
341
+ throw new Error("A folder with that name already exists.");
217
342
  },
218
343
  });
219
- chooseCreateOption();
220
- const nameInput = container.querySelector(
221
- 'input[aria-label="New folder name"]',
222
- ) as HTMLInputElement;
223
- setInput(nameInput, "Receipts");
224
- await act(async () => {
225
- button("Create folder")?.click();
226
- });
344
+ await openTree();
345
+ await click(byAriaLabel("New folder"));
346
+ await typeName("Receipts");
347
+ await click(byText("Create folder"));
348
+ assert.deepEqual(picked, []);
349
+ assert.ok(nameField(), "the name field stays open");
227
350
  assert.match(
228
351
  container.querySelector('[role="alert"]')?.textContent ?? "",
229
- /Couldn't create that folder/,
230
- );
231
- });
232
-
233
- it("cancels the create field and leaves the destination unchanged", () => {
234
- const moved: string[] = [];
235
- mount({
236
- onChangeMove: (id) => moved.push(id),
237
- onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
238
- });
239
- chooseCreateOption();
240
- act(() => {
241
- button("Cancel")?.click();
242
- });
243
- assert.equal(
244
- container.querySelector('input[aria-label="New folder name"]'),
245
- null,
352
+ /already exists/,
246
353
  );
247
- assert.deepEqual(moved, []);
248
354
  });
249
355
  });
@@ -13,7 +13,6 @@ import {
13
13
  demoSubjectPrefillRule,
14
14
  demoVocabularyRule,
15
15
  type FilterRule,
16
- type FolderOption,
17
16
  type LabelOption,
18
17
  type PreviewCount,
19
18
  type RuleClause,
@@ -24,6 +23,7 @@ import {
24
23
  FilterRuleEditor,
25
24
  type FilterRuleEditorProps,
26
25
  } from "./filter-rule-editor.js";
26
+ import type { FolderTreeNode } from "./folder-tree-picker.js";
27
27
 
28
28
  const meta: Meta<typeof FilterRuleEditor> = {
29
29
  title: "FilterRuleEditor",
@@ -75,8 +75,9 @@ function LiveEditor({
75
75
  labels?: LabelOption[];
76
76
  onCreateFolder?: (
77
77
  name: string,
78
+ parentPath: string,
78
79
  signal?: AbortSignal,
79
- ) => Promise<FolderOption>;
80
+ ) => Promise<FolderTreeNode>;
80
81
  onCreateLabel?: (name: string) => Promise<LabelOption>;
81
82
  }) {
82
83
  const [rule, setRule] = useState<FilterRule>(initialRule);
@@ -323,95 +324,184 @@ export const SubjectClauseStaysFreeText: Story = {
323
324
  ),
324
325
  };
325
326
 
327
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
328
+
329
+ // The play functions fail loudly rather than quietly rendering a story that
330
+ // documents a state it never reached.
331
+ const clickText = (canvasElement: HTMLElement, label: string) => {
332
+ const button = Array.from(
333
+ canvasElement.querySelectorAll<HTMLButtonElement>("button"),
334
+ ).find((candidate) => candidate.textContent?.trim() === label);
335
+ if (!button) throw new Error(`no button reading "${label}"`);
336
+ button.click();
337
+ };
338
+
339
+ const clickAriaLabel = (canvasElement: HTMLElement, label: string) => {
340
+ const control = canvasElement.querySelector<HTMLElement>(
341
+ `[aria-label="${label}"]`,
342
+ );
343
+ if (!control) throw new Error(`no control labelled "${label}"`);
344
+ control.click();
345
+ };
346
+
347
+ const setInputValue = (input: HTMLInputElement, value: string) => {
348
+ Object.getOwnPropertyDescriptor(
349
+ HTMLInputElement.prototype,
350
+ "value",
351
+ )?.set?.call(input, value);
352
+ input.dispatchEvent(new Event("input", { bubbles: true }));
353
+ };
354
+
355
+ /** The create form's name field, found by the label the kit gives it. */
356
+ const typeFolderName = (canvasElement: HTMLElement, name: string) => {
357
+ const label = Array.from(canvasElement.querySelectorAll("label")).find(
358
+ (node) => node.textContent?.trim() === "Folder name",
359
+ );
360
+ const id = label?.getAttribute("for");
361
+ const input = id
362
+ ? canvasElement.querySelector<HTMLInputElement>(`input[id="${id}"]`)
363
+ : null;
364
+ if (!input) throw new Error("the folder name field is not on screen");
365
+ setInputValue(input, name);
366
+ };
367
+
368
+ /** Opens the destination tree, which the field keeps closed until it is asked for. */
369
+ const openDestinationTree = async (canvasElement: HTMLElement) => {
370
+ clickText(canvasElement, "Choose a folder");
371
+ await tick();
372
+ };
373
+
374
+ /** Opens a folder in the tree; tapping it both picks it and reveals its children. */
375
+ const openFolder = async (canvasElement: HTMLElement, label: string) => {
376
+ clickAriaLabel(canvasElement, `Move to ${label}`);
377
+ await tick();
378
+ };
379
+
380
+ /**
381
+ * Drive the destination tree into its create form and submit it. `inside` names
382
+ * the folder the new one is made in; omitted makes it at the top level. Used by
383
+ * the pending and error stories below so each lands in the state it documents.
384
+ */
385
+ async function createFolderFromTree(
386
+ canvasElement: HTMLElement,
387
+ folderName: string,
388
+ inside?: string,
389
+ ) {
390
+ await openDestinationTree(canvasElement);
391
+ if (inside) {
392
+ await openFolder(canvasElement, "Inbox");
393
+ await openFolder(canvasElement, inside);
394
+ clickAriaLabel(canvasElement, `New folder inside ${inside}`);
395
+ } else {
396
+ clickAriaLabel(canvasElement, "New folder");
397
+ }
398
+ await tick();
399
+ typeFolderName(canvasElement, folderName);
400
+ await tick();
401
+ clickText(canvasElement, "Create folder");
402
+ }
403
+
326
404
  let newFolderSeq = 0;
327
- const mockCreateFolder = (name: string): Promise<FolderOption> =>
405
+ const mockCreateFolder = (
406
+ name: string,
407
+ parentPath: string,
408
+ ): Promise<FolderTreeNode> =>
328
409
  new Promise((resolve) => {
329
410
  newFolderSeq += 1;
330
411
  setTimeout(
331
- () => resolve({ id: `mbx-new-${newFolderSeq}`, label: name }),
412
+ () =>
413
+ resolve({
414
+ id: `mbx-new-${newFolderSeq}`,
415
+ label: name,
416
+ path: parentPath ? `${parentPath}/${name}` : name,
417
+ }),
332
418
  400,
333
419
  );
334
420
  });
335
421
 
336
422
  /**
337
- * The move destination offers a "+ New folder…" option because `onCreateFolder`
338
- * is wired. Choosing it reveals a name field; on resolve the folder is added to
339
- * the select and picked as the destination. Without the prop the option never
340
- * shows — the editor stays data-agnostic.
423
+ * The destination is chosen from the same browsable tree every other picker
424
+ * uses: open a folder to see what is inside it, filter to narrow, and make a
425
+ * new one where you are looking.
341
426
  */
342
- export const WithNewFolderOption: Story = {
427
+ export const DestinationTree: Story = {
428
+ name: "Destination — browse the folder tree",
343
429
  render: () => (
344
430
  <LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
345
431
  ),
432
+ play: async ({ canvasElement }) => {
433
+ await openDestinationTree(canvasElement);
434
+ },
346
435
  };
347
436
 
348
437
  /**
349
- * Drive the destination field into its create sub-form: pick "+ New folder…",
350
- * type a name, and press "Create folder". Used by the pending and error stories
351
- * below so each lands in the state it documents without a manual click-through.
438
+ * Two folders named Receipts, one at the top level and one inside Travel. The
439
+ * tree tells them apart by where they sit; a list of leaf names could not.
352
440
  */
353
- async function openCreateAndSubmit(
354
- canvasElement: HTMLElement,
355
- folderName: string,
356
- ) {
357
- const setSelectValue = Object.getOwnPropertyDescriptor(
358
- HTMLSelectElement.prototype,
359
- "value",
360
- )?.set;
361
- const setInputValue = Object.getOwnPropertyDescriptor(
362
- HTMLInputElement.prototype,
363
- "value",
364
- )?.set;
365
- const select = canvasElement.querySelector<HTMLSelectElement>(
366
- 'select[aria-label="Destination folder"]',
367
- );
368
- if (!select) return;
369
- setSelectValue?.call(select, CREATE_FOLDER_STORY_VALUE);
370
- select.dispatchEvent(new Event("change", { bubbles: true }));
371
- const input = canvasElement.querySelector<HTMLInputElement>(
372
- 'input[aria-label="New folder name"]',
373
- );
374
- if (!input) return;
375
- setInputValue?.call(input, folderName);
376
- input.dispatchEvent(new Event("input", { bubbles: true }));
377
- const createButton = Array.from(
378
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
379
- ).find((button) => button.textContent?.trim() === "Create folder");
380
- createButton?.click();
381
- }
441
+ export const DestinationNestedFolders: Story = {
442
+ name: "Destination — a nested folder and its same-named sibling",
443
+ render: () => (
444
+ <LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
445
+ ),
446
+ play: async ({ canvasElement }) => {
447
+ await openDestinationTree(canvasElement);
448
+ await openFolder(canvasElement, "Inbox");
449
+ await openFolder(canvasElement, "Travel");
450
+ },
451
+ };
382
452
 
383
- /** Matches the internal CREATE_FOLDER_VALUE option in the destination select. */
384
- const CREATE_FOLDER_STORY_VALUE = "__filter_create_folder__";
453
+ /**
454
+ * A new folder is made inside the folder the tree is looking at, so a filter can
455
+ * point at `Travel/Car hire` without leaving the editor.
456
+ */
457
+ export const NewFolderInsideAnother: Story = {
458
+ name: "New folder — inside the folder you opened",
459
+ render: () => (
460
+ <LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
461
+ ),
462
+ play: async ({ canvasElement }) => {
463
+ await openDestinationTree(canvasElement);
464
+ await openFolder(canvasElement, "Inbox");
465
+ await openFolder(canvasElement, "Travel");
466
+ clickAriaLabel(canvasElement, "New folder inside Travel");
467
+ await tick();
468
+ typeFolderName(canvasElement, "Car hire");
469
+ },
470
+ };
385
471
 
386
472
  /** Mirrors the web-client wait's honest timeout copy. */
387
473
  const TIMEOUT_MESSAGE =
388
474
  "The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
389
475
 
390
- const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
391
-
392
- const neverResolvesCreateFolder = (): Promise<FolderOption> =>
393
- new Promise<FolderOption>(() => undefined);
476
+ const neverResolvesCreateFolder = (): Promise<FolderTreeNode> =>
477
+ new Promise<FolderTreeNode>(() => undefined);
394
478
 
395
- const rejectingCreateFolder = (message: string) => (): Promise<FolderOption> =>
396
- Promise.reject(new Error(message));
479
+ const rejectingCreateFolder =
480
+ (message: string) => (): Promise<FolderTreeNode> =>
481
+ Promise.reject(new Error(message));
397
482
 
398
483
  /** Rejects the first attempt, resolves the retry — the resume the hook performs. */
399
484
  const failThenSucceedCreateFolder = () => {
400
485
  let attempts = 0;
401
- return (name: string): Promise<FolderOption> => {
486
+ return (name: string, parentPath: string): Promise<FolderTreeNode> => {
402
487
  attempts += 1;
403
488
  return attempts === 1
404
489
  ? Promise.reject(new Error(TIMEOUT_MESSAGE))
405
- : Promise.resolve({ id: "mbx-created", label: name });
490
+ : Promise.resolve({
491
+ id: "mbx-created",
492
+ label: name,
493
+ path: parentPath ? `${parentPath}/${name}` : name,
494
+ });
406
495
  };
407
496
  };
408
497
 
409
498
  /** Never resolves on its own; rejects with an AbortError when the signal aborts. */
410
499
  const abortAwareCreateFolder = (
411
500
  _name: string,
501
+ _parentPath: string,
412
502
  signal?: AbortSignal,
413
- ): Promise<FolderOption> =>
414
- new Promise<FolderOption>((_resolve, reject) => {
503
+ ): Promise<FolderTreeNode> =>
504
+ new Promise<FolderTreeNode>((_resolve, reject) => {
415
505
  signal?.addEventListener("abort", () =>
416
506
  reject(new DOMException("Aborted", "AbortError")),
417
507
  );
@@ -420,8 +510,7 @@ const abortAwareCreateFolder = (
420
510
  /**
421
511
  * The folder is a dependent write for the filter, so creating it waits for the
422
512
  * mail server to confirm the folder before it can be picked as the destination.
423
- * The wait shows as "Creating folder…" held for the whole confirmation, not
424
- * just a fast optimistic round-trip.
513
+ * The wait is held in the form, which refuses a second submit while it runs.
425
514
  */
426
515
  export const NewFolderCreating: Story = {
427
516
  name: "New folder — creating (waiting for the server)",
@@ -432,7 +521,7 @@ export const NewFolderCreating: Story = {
432
521
  />
433
522
  ),
434
523
  play: async ({ canvasElement }) => {
435
- await openCreateAndSubmit(canvasElement, "Receipts");
524
+ await createFolderFromTree(canvasElement, "Receipts");
436
525
  },
437
526
  };
438
527
 
@@ -452,7 +541,7 @@ export const NewFolderCreateFailed: Story = {
452
541
  />
453
542
  ),
454
543
  play: async ({ canvasElement }) => {
455
- await openCreateAndSubmit(canvasElement, "Receipts");
544
+ await createFolderFromTree(canvasElement, "Receipts");
456
545
  },
457
546
  };
458
547
 
@@ -470,7 +559,7 @@ export const NewFolderCreateTimedOut: Story = {
470
559
  />
471
560
  ),
472
561
  play: async ({ canvasElement }) => {
473
- await openCreateAndSubmit(canvasElement, "Receipts");
562
+ await createFolderFromTree(canvasElement, "Receipts");
474
563
  },
475
564
  };
476
565
 
@@ -489,19 +578,16 @@ export const NewFolderCreateRetrySucceeds: Story = {
489
578
  />
490
579
  ),
491
580
  play: async ({ canvasElement }) => {
492
- await openCreateAndSubmit(canvasElement, "Receipts");
581
+ await createFolderFromTree(canvasElement, "Receipts", "Travel");
493
582
  await tick();
494
- const retry = Array.from(
495
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
496
- ).find((button) => button.textContent?.trim() === "Create folder");
497
- retry?.click();
583
+ clickText(canvasElement, "Create folder");
498
584
  },
499
585
  };
500
586
 
501
587
  /**
502
- * Cancelling while "Creating folder…" is in flight aborts the wait: the create
503
- * promise rejects with an AbortError the field swallows, so no destination binds
504
- * after the user backed out — the sub-form just closes.
588
+ * Cancelling while the create is in flight aborts the wait: the create promise
589
+ * rejects with an AbortError the form swallows, so no destination binds after
590
+ * the user backed out — the form just closes.
505
591
  */
506
592
  export const NewFolderCreateCancelledMidWait: Story = {
507
593
  name: "New folder — cancel aborts the wait",
@@ -512,12 +598,9 @@ export const NewFolderCreateCancelledMidWait: Story = {
512
598
  />
513
599
  ),
514
600
  play: async ({ canvasElement }) => {
515
- await openCreateAndSubmit(canvasElement, "Receipts");
601
+ await createFolderFromTree(canvasElement, "Receipts");
516
602
  await tick();
517
- const cancel = Array.from(
518
- canvasElement.querySelectorAll<HTMLButtonElement>("button"),
519
- ).find((button) => button.textContent?.trim() === "Cancel");
520
- cancel?.click();
603
+ clickText(canvasElement, "Cancel");
521
604
  },
522
605
  };
523
606
 
@@ -1,12 +1,5 @@
1
- import {
2
- Fragment,
3
- type ReactNode,
4
- useEffect,
5
- useMemo,
6
- useRef,
7
- useState,
8
- } from "react";
9
- import { isAbortError } from "../lib/abort.js";
1
+ import { Fragment, type ReactNode, useMemo, useState } from "react";
2
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
10
3
  import { Button } from "./button.js";
11
4
  import {
12
5
  AddChipButton,
@@ -21,7 +14,6 @@ import {
21
14
  commitBlockedReason,
22
15
  commitLabel,
23
16
  type FilterRule,
24
- type FolderOption,
25
17
  type LabelOption,
26
18
  type MatchOperator,
27
19
  matchJoinWord,
@@ -32,6 +24,7 @@ import {
32
24
  type RuleMatchMode,
33
25
  type RuleScope,
34
26
  } from "./filter-rule.js";
27
+ import { FolderTreePicker } from "./folder-tree-picker.js";
35
28
  import { Input } from "./input.js";
36
29
  import { LabelChip } from "./label-chip.js";
37
30
  import { SegmentedControl } from "./segmented-control.js";
@@ -47,7 +40,10 @@ export interface ClauseEditState {
47
40
 
48
41
  export interface FilterRuleEditorProps {
49
42
  rule: FilterRule;
50
- folders: FolderOption[];
43
+ /** Destinations as the app has them — labelled, and pathed by the provider. */
44
+ folders: readonly FolderTreeNode[];
45
+ /** The provider's hierarchy separator, which the destination tree nests on. */
46
+ delimiter?: string;
51
47
  /** The account's labels the apply-label action can target (issue #26). */
52
48
  labels?: LabelOption[];
53
49
  preview: PreviewCount;
@@ -113,16 +109,19 @@ export interface FilterRuleEditorProps {
113
109
  onChangeMatchOperator?: (operator: MatchOperator) => void;
114
110
  onChangeMove?: (mailboxId: string) => void;
115
111
  /**
116
- * Create a new destination folder from within the editor. Given a folder name
117
- * and an abort signal, resolves to the created folder once the mail server
118
- * confirms it. The editor aborts the signal on unmount or cancel. When absent,
119
- * the "New folder…" option is not offered — the editor stays data-agnostic, so
120
- * stories and consumers without wiring render unchanged.
112
+ * Create a new destination folder from within the editor, inside the folder
113
+ * the tree is looking at. Creating a folder is an IMAP mutation, so this
114
+ * resolves only once the mail server confirms it
115
+ * (docs/architecture/imap-mutations.md); the picker holds the wait, states a
116
+ * failure where it happened, and aborts the signal on unmount or cancel, so
117
+ * the editor can never commit a filter against a folder that does not exist.
118
+ * Absent renders no create affordance.
121
119
  */
122
120
  onCreateFolder?: (
123
121
  name: string,
122
+ parentPath: string,
124
123
  signal?: AbortSignal,
125
- ) => Promise<FolderOption>;
124
+ ) => Promise<FolderTreeNode>;
126
125
  onChangeLabel?: (labelId: string) => void;
127
126
  /**
128
127
  * Create a new label from within the editor (issue #26). Given a label
@@ -153,47 +152,43 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
153
152
  { value: "until", label: "Until a date" },
154
153
  ];
155
154
 
156
- const CREATE_FOLDER_VALUE = "__filter_create_folder__";
157
155
  const CREATE_LABEL_VALUE = "__filter_create_label__";
158
156
 
159
157
  /**
160
- * The move-to destination select, plus an inline "New folder…" affordance when
161
- * the consumer wires `onCreateFolder`. Selecting the create option reveals a
162
- * name field; on resolve the new folder is added to the local option set (so it
163
- * is selectable even before the caller's folder list refetches) and picked as
164
- * the destination. Without `onCreateFolder` this is the bare select.
158
+ * The move-to destination: the same browsable folder tree every other picker
159
+ * uses, opened from a line that states where matches go now. A folder is
160
+ * labelled as the app labels it, nested where the provider nests it, and a new
161
+ * one is made wherever the tree is looking.
162
+ *
163
+ * Tapping a folder both picks it and opens it, so the rule is re-pointed on an
164
+ * explicit confirmation rather than on the way past — otherwise looking inside
165
+ * a folder on the way to a nested one would rewrite the filter with no undo.
165
166
  *
166
- * The destination is a dependent write: the filter this editor commits binds to
167
- * the folder, so `onCreateFolder` resolves only once the folder is confirmed on
168
- * the mail server, not when the create is merely queued. The pending state holds
169
- * "Creating folder…" for that whole wait, and a create that fails or never
170
- * confirms rejects with its own message here — the folder is never selected, so
171
- * the caller cannot commit a filter against a folder that does not exist.
167
+ * The destination is a dependent write the filter binds to the folder — so a
168
+ * created folder is offered only once the mail server has confirmed it. It is
169
+ * held here as well as returned, so it is pickable before the caller's folder
170
+ * list has refetched.
172
171
  */
173
172
  function MoveDestinationField({
174
173
  folders,
175
174
  value,
175
+ delimiter = "/",
176
176
  onChangeMove,
177
177
  onCreateFolder,
178
178
  }: {
179
- folders: FolderOption[];
180
- value: string;
179
+ folders: readonly FolderTreeNode[];
180
+ value?: string;
181
+ delimiter?: string;
181
182
  onChangeMove?: (mailboxId: string) => void;
182
183
  onCreateFolder?: (
183
184
  name: string,
185
+ parentPath: string,
184
186
  signal?: AbortSignal,
185
- ) => Promise<FolderOption>;
187
+ ) => Promise<FolderTreeNode>;
186
188
  }) {
187
- const [creating, setCreating] = useState(false);
188
- const [name, setName] = useState("");
189
- const [pending, setPending] = useState(false);
190
- const [error, setError] = useState<string>();
191
- const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
192
- // The create waits for the mail server to confirm the folder; abort it on
193
- // unmount or cancel so a late confirmation never binds the destination after
194
- // the editor is gone or the sub-form dismissed.
195
- const createAbort = useRef<AbortController | null>(null);
196
- useEffect(() => () => createAbort.current?.abort(), []);
189
+ const [browsing, setBrowsing] = useState(false);
190
+ const [picked, setPicked] = useState<string>();
191
+ const [createdFolders, setCreatedFolders] = useState<FolderTreeNode[]>([]);
197
192
 
198
193
  const options = useMemo(() => {
199
194
  const known = new Set(folders.map((folder) => folder.id));
@@ -203,127 +198,114 @@ function MoveDestinationField({
203
198
  ];
204
199
  }, [folders, createdFolders]);
205
200
 
206
- const handleSelectChange = (next: string) => {
207
- if (next === CREATE_FOLDER_VALUE) {
208
- setError(undefined);
209
- setCreating(true);
210
- return;
211
- }
212
- onChangeMove?.(next);
213
- };
214
-
215
- const submit = () => {
216
- if (!onCreateFolder) return;
217
- const trimmed = name.trim();
218
- if (trimmed === "") return;
219
- setPending(true);
220
- setError(undefined);
221
- createAbort.current?.abort();
222
- const controller = new AbortController();
223
- createAbort.current = controller;
224
- onCreateFolder(trimmed, controller.signal)
225
- .then((folder) => {
226
- setCreatedFolders((prev) =>
227
- prev.some((entry) => entry.id === folder.id)
228
- ? prev
229
- : [...prev, folder],
230
- );
231
- onChangeMove?.(folder.id);
232
- setCreating(false);
233
- setName("");
234
- setPending(false);
201
+ const createFolder = useMemo(() => {
202
+ if (!onCreateFolder) return undefined;
203
+ return async (name: string, parentPath: string, signal?: AbortSignal) => {
204
+ const created = await onCreateFolder(name, parentPath, signal);
205
+ setCreatedFolders((prev) =>
206
+ prev.some((entry) => entry.id === created.id)
207
+ ? prev
208
+ : [...prev, created],
209
+ );
210
+ return created;
211
+ };
212
+ }, [onCreateFolder]);
213
+
214
+ const chosen = options.find((folder) => folder.id === value);
215
+ const pending = options.find((folder) => folder.id === picked);
216
+
217
+ /** Two folders can share a leaf name, so a destination reads as its trail. */
218
+ const trail = (folder: FolderTreeNode): string => {
219
+ const segments = folder.path.split(delimiter);
220
+ return segments
221
+ .map((segment, index) => {
222
+ const path = segments.slice(0, index + 1).join(delimiter);
223
+ return options.find((option) => option.path === path)?.label ?? segment;
235
224
  })
236
- .catch((error: unknown) => {
237
- if (isAbortError(error)) return;
238
- setError(
239
- error instanceof Error
240
- ? error.message
241
- : "Couldn't create that folder. Please try again.",
242
- );
243
- setPending(false);
244
- });
225
+ .join(" / ");
245
226
  };
246
227
 
247
- const cancel = () => {
248
- createAbort.current?.abort();
249
- setCreating(false);
250
- setName("");
251
- setError(undefined);
252
- setPending(false);
228
+ const close = () => {
229
+ setBrowsing(false);
230
+ setPicked(undefined);
253
231
  };
254
232
 
255
- return (
256
- <div className="space-y-2">
257
- <Select
258
- aria-label="Destination folder"
259
- value={value}
260
- onChange={(event) => handleSelectChange(event.target.value)}
261
- >
262
- <option value="">Choose a folder…</option>
263
- {options.map((folder) => (
264
- <option key={folder.id} value={folder.id}>
265
- {folder.label}
266
- </option>
267
- ))}
268
- {onCreateFolder && (
269
- <option value={CREATE_FOLDER_VALUE}>+ New folder…</option>
270
- )}
271
- </Select>
272
- {creating && (
273
- <div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
274
- <Input
275
- value={name}
276
- onChange={(event) => setName(event.target.value)}
277
- placeholder="Folder name"
278
- aria-label="New folder name"
279
- disabled={pending}
280
- autoFocus
281
- onKeyDown={(event) => {
282
- if (event.key === "Enter") {
283
- event.preventDefault();
284
- submit();
285
- }
286
- if (event.key === "Escape") {
287
- event.preventDefault();
288
- cancel();
289
- }
233
+ if (!browsing) {
234
+ return (
235
+ <div className="rounded-md border border-line bg-surface-sunken p-3">
236
+ <p className="text-sm font-medium text-fg">
237
+ {chosen ? trail(chosen) : "No folder yet"}
238
+ </p>
239
+ <div className="mt-1 flex flex-wrap items-center gap-3">
240
+ <Button
241
+ variant="ghost"
242
+ size="sm"
243
+ className="px-0"
244
+ onClick={() => {
245
+ setPicked(value);
246
+ setBrowsing(true);
290
247
  }}
291
- />
292
- {error && (
293
- <p className="text-2xs text-danger" role="alert">
294
- {error}
295
- </p>
296
- )}
297
- <div className="flex gap-2">
298
- <Button
299
- variant="primary"
300
- size="sm"
301
- onClick={submit}
302
- disabled={pending || name.trim() === ""}
303
- >
304
- {pending ? "Creating folder…" : "Create folder"}
305
- </Button>
248
+ >
249
+ Choose a folder
250
+ </Button>
251
+ {chosen && (
306
252
  <Button
307
253
  variant="ghost"
308
254
  size="sm"
309
- onClick={cancel}
310
- disabled={pending}
255
+ className="px-0"
256
+ onClick={() => onChangeMove?.("")}
311
257
  >
312
- Cancel
258
+ Don't move matches
313
259
  </Button>
314
- </div>
260
+ )}
315
261
  </div>
262
+ </div>
263
+ );
264
+ }
265
+
266
+ return (
267
+ <div className="space-y-2">
268
+ <div className="flex h-72 min-h-0 overflow-hidden rounded-md border border-line bg-surface">
269
+ <FolderTreePicker
270
+ folders={options}
271
+ selectedId={picked}
272
+ delimiter={delimiter}
273
+ onSelect={setPicked}
274
+ onCreateFolder={createFolder}
275
+ onCancel={close}
276
+ labels={{
277
+ createPending: "Waiting for the mail server to confirm the folder…",
278
+ }}
279
+ />
280
+ </div>
281
+ {pending ? (
282
+ <Button
283
+ variant="primary"
284
+ onClick={() => {
285
+ onChangeMove?.(pending.id);
286
+ close();
287
+ }}
288
+ className="w-full"
289
+ >
290
+ <span className="truncate">{`Move matches to ${trail(pending)}`}</span>
291
+ </Button>
292
+ ) : (
293
+ <p className="text-2xs text-fg-subtle">
294
+ Tap a folder to open it, or make a new one where you want it.
295
+ </p>
316
296
  )}
297
+ <Button variant="ghost" size="sm" onClick={close} className="w-full">
298
+ Cancel
299
+ </Button>
317
300
  </div>
318
301
  );
319
302
  }
320
303
 
321
304
  /**
322
305
  * The apply-label select, plus an inline "New label…" affordance when the
323
- * consumer wires `onCreateLabel` (issue #26). Mirrors `MoveDestinationField`:
324
- * selecting the create option reveals a name field, and the created label is
325
- * added to the local option set and picked immediately, before the caller's
326
- * label list refetches.
306
+ * consumer wires `onCreateLabel` (issue #26). Selecting the create option
307
+ * reveals a name field, and the created label is added to the local option set
308
+ * and picked immediately, before the caller's label list refetches.
327
309
  */
328
310
  function LabelDestinationField({
329
311
  labels,
@@ -468,6 +450,7 @@ function LabelDestinationField({
468
450
  export function FilterRuleEditor({
469
451
  rule,
470
452
  folders,
453
+ delimiter,
471
454
  labels = [],
472
455
  preview,
473
456
  notice,
@@ -622,7 +605,8 @@ export function FilterRuleEditor({
622
605
  <p className="text-xs font-medium text-fg-muted">Move matches to</p>
623
606
  <MoveDestinationField
624
607
  folders={folders}
625
- value={rule.moveMailboxId ?? ""}
608
+ value={rule.moveMailboxId}
609
+ delimiter={delimiter}
626
610
  onChangeMove={onChangeMove}
627
611
  onCreateFolder={onCreateFolder}
628
612
  />
@@ -20,7 +20,6 @@ import {
20
20
  demoSenderFallbackRule,
21
21
  demoVocabularyRule,
22
22
  type FilterRule,
23
- type FolderOption,
24
23
  type LabelOption,
25
24
  matchJoinWord,
26
25
  matchOperatorLabel,
@@ -34,14 +33,15 @@ import {
34
33
  FilterRuleEditor,
35
34
  type FilterRuleEditorProps,
36
35
  } from "./filter-rule-editor.js";
36
+ import type { FolderTreeNode } from "./folder-tree-picker.js";
37
37
 
38
38
  /** SSR splits interpolations with comment markers; sentences read across them. */
39
39
  const render = (element: Parameters<typeof renderToString>[0]) =>
40
40
  renderToString(element).replaceAll("<!-- -->", "");
41
41
 
42
- const FOLDERS: FolderOption[] = [
43
- { id: "mbx-inbox", label: "Inbox" },
44
- { id: "mbx-archive", label: "Archive" },
42
+ const FOLDERS: FolderTreeNode[] = [
43
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
44
+ { id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
45
45
  ];
46
46
 
47
47
  const LABELS: LabelOption[] = [
@@ -7,6 +7,7 @@
7
7
  * Naming tracks the RFC: rule, clause, widen, scope.
8
8
  */
9
9
 
10
+ import type { FolderTreeNode } from "../lib/folder-tree.js";
10
11
  import type { Suggestion } from "./suggest-list.js";
11
12
 
12
13
  /**
@@ -105,11 +106,6 @@ export interface FilterRule {
105
106
  name?: string;
106
107
  }
107
108
 
108
- export interface FolderOption {
109
- id: string;
110
- label: string;
111
- }
112
-
113
109
  export interface LabelOption {
114
110
  id: string;
115
111
  name: string;
@@ -313,12 +309,22 @@ export function commitBlockedReason(
313
309
  return previewSettledReason(preview);
314
310
  }
315
311
 
316
- export const demoFolders: FolderOption[] = [
317
- { id: "mbx-inbox", label: "Inbox" },
318
- { id: "mbx-archive", label: "Archive" },
319
- { id: "mbx-receipts", label: "Receipts" },
320
- { id: "mbx-travel", label: "Travel" },
321
- { id: "mbx-junk", label: "Junk" },
312
+ // Two folders named Receipts at different depths, and a Trash labelled by its
313
+ // role rather than its provider leaf: what a flat list of leaf names cannot
314
+ // tell apart.
315
+ export const demoFolders: FolderTreeNode[] = [
316
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
317
+ { id: "mbx-archive", label: "Archive", path: "INBOX/Archive" },
318
+ { id: "mbx-receipts", label: "Receipts", path: "INBOX/Receipts" },
319
+ { id: "mbx-receipts-2026", label: "2026", path: "INBOX/Receipts/2026" },
320
+ { id: "mbx-travel", label: "Travel", path: "INBOX/Travel" },
321
+ {
322
+ id: "mbx-travel-receipts",
323
+ label: "Receipts",
324
+ path: "INBOX/Travel/Receipts",
325
+ },
326
+ { id: "mbx-junk", label: "Junk", path: "INBOX/Junk" },
327
+ { id: "mbx-trash", label: "Trash", path: "INBOX/Prullenbak" },
322
328
  ];
323
329
 
324
330
  export const demoLabels: LabelOption[] = [
@@ -146,7 +146,12 @@ export const FolderTreePicker = ({
146
146
  }: FolderTreePickerProps) => {
147
147
  const text = { ...defaultLabels, ...labels };
148
148
  const [query, setQuery] = useState("");
149
- const [opened, setOpened] = useState<ReadonlySet<string>>(new Set());
149
+ // A destination chosen before the picker opened is out of reach behind its
150
+ // ancestors, so the branch holding it starts open and the choice is on screen.
151
+ const [opened, setOpened] = useState<ReadonlySet<string>>(() => {
152
+ const selected = folders.find((folder) => folder.id === selectedId);
153
+ return new Set(selected ? folderAncestors(selected.path, delimiter) : []);
154
+ });
150
155
  const [draft, setDraft] = useState<Draft | null>(null);
151
156
  const [draftName, setDraftName] = useState("");
152
157
  const [draftError, setDraftError] = useState<string>();
package/src/index.ts CHANGED
@@ -170,7 +170,6 @@ export {
170
170
  demoSubjectPrefillRule,
171
171
  demoVocabularyRule,
172
172
  type FilterRule,
173
- type FolderOption,
174
173
  hasActiveWiden,
175
174
  type LabelOption,
176
175
  type MatchOperator,