@remit/ui 0.0.66 → 0.0.67

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.66",
3
+ "version": "0.0.67",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,5 +1,6 @@
1
1
  /**
2
- * The inline create form: where it opens, what it says the parent is, and how it
2
+ * Opening folders, and the inline create form that sits at the end of an opened
3
+ * folder's children: where it opens, what it says the parent is, and how it
3
4
  * behaves while the mail server is confirming the folder. Mounted against jsdom
4
5
  * for the anchoring, the field state and the async resolve. React is imported
5
6
  * after the jsdom globals are installed so the controlled value tracker binds to
@@ -11,6 +12,7 @@ import type { JSDOM } from "jsdom";
11
12
  import type {
12
13
  act as reactAct,
13
14
  createElement as reactCreateElement,
15
+ useState as reactUseState,
14
16
  } from "react";
15
17
  import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
16
18
  import type {
@@ -30,6 +32,7 @@ let container: HTMLElement;
30
32
  let root: Root;
31
33
  let act: typeof reactAct;
32
34
  let createElement: typeof reactCreateElement;
35
+ let useState: typeof reactUseState;
33
36
  let createRoot: typeof reactCreateRoot;
34
37
  let FolderTreePicker: typeof FolderTreePickerType;
35
38
 
@@ -55,6 +58,7 @@ before(async () => {
55
58
  const react = await import("react");
56
59
  act = react.act;
57
60
  createElement = react.createElement;
61
+ useState = react.useState;
58
62
  ({ createRoot } = await import("react-dom/client"));
59
63
  ({ FolderTreePicker } = await import("./folder-tree-picker.js"));
60
64
  });
@@ -76,9 +80,9 @@ afterEach(async () => {
76
80
  container.remove();
77
81
  });
78
82
 
79
- const mount = async (
80
- props: Partial<Parameters<typeof FolderTreePickerType>[0]> = {},
81
- ) => {
83
+ type PickerProps = Partial<Parameters<typeof FolderTreePickerType>[0]>;
84
+
85
+ const mount = async (props: PickerProps = {}) => {
82
86
  await act(async () => {
83
87
  root.render(
84
88
  createElement(FolderTreePicker, {
@@ -90,6 +94,22 @@ const mount = async (
90
94
  });
91
95
  };
92
96
 
97
+ /** Holds the chosen destination the way the app does, so a tap can be undone. */
98
+ const mountControlled = async (props: PickerProps = {}) => {
99
+ const Controlled = () => {
100
+ const [selected, setSelected] = useState<string>();
101
+ return createElement(FolderTreePicker, {
102
+ folders,
103
+ selectedId: selected,
104
+ onSelect: setSelected,
105
+ ...props,
106
+ });
107
+ };
108
+ await act(async () => {
109
+ root.render(createElement(Controlled));
110
+ });
111
+ };
112
+
93
113
  const click = async (element: Element | null | undefined) => {
94
114
  assert.ok(element, "control not rendered");
95
115
  await act(async () => {
@@ -97,6 +117,13 @@ const click = async (element: Element | null | undefined) => {
97
117
  });
98
118
  };
99
119
 
120
+ const focus = async (element: Element | null | undefined) => {
121
+ assert.ok(element, "control not rendered");
122
+ await act(async () => {
123
+ (element as HTMLElement).focus();
124
+ });
125
+ };
126
+
100
127
  const byAriaLabel = (label: string): HTMLElement | null =>
101
128
  container.querySelector(`[aria-label="${label}"]`);
102
129
 
@@ -121,6 +148,16 @@ const typeName = async (value: string) => {
121
148
  });
122
149
  };
123
150
 
151
+ const open = async (...labels: string[]) => {
152
+ for (const label of labels) {
153
+ await click(byAriaLabel(`Move to ${label}`));
154
+ }
155
+ };
156
+
157
+ /** The block holding a folder's create action and any form opened from it. */
158
+ const createBlockOf = (label: string): Element | null =>
159
+ byAriaLabel(`New folder inside ${label}`)?.closest('[role="none"]') ?? null;
160
+
124
161
  const rowOf = (label: string): Element | null =>
125
162
  byAriaLabel(`Move to ${label}`)?.closest('[role="none"]') ?? null;
126
163
 
@@ -130,44 +167,118 @@ const created = (name: string, parentPath: string): FolderTreeNode => ({
130
167
  path: parentPath ? `${parentPath}/${name}` : name,
131
168
  });
132
169
 
170
+ const resolving = (name: string, parentPath: string) =>
171
+ Promise.resolve(created(name, parentPath));
172
+
173
+ describe("opening folders", () => {
174
+ it("starts at the top level with everything closed", async () => {
175
+ await mount({});
176
+ assert.ok(byAriaLabel("Move to Travel"));
177
+ assert.ok(byAriaLabel("Move to Archive"));
178
+ assert.equal(byAriaLabel("Move to Hotels"), null);
179
+ assert.equal(
180
+ byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
181
+ "false",
182
+ );
183
+ });
184
+
185
+ it("picks the destination and opens it in one tap", async () => {
186
+ const selected: string[] = [];
187
+ await mount({ onSelect: (id) => selected.push(id) });
188
+ await open("Travel");
189
+ assert.deepEqual(selected, ["travel"]);
190
+ assert.ok(byAriaLabel("Move to Hotels"));
191
+ assert.equal(
192
+ byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
193
+ "true",
194
+ );
195
+ assert.equal(
196
+ byAriaLabel("Move to Hotels")?.getAttribute("aria-level"),
197
+ "2",
198
+ );
199
+ });
200
+
201
+ it("closes on a second tap and keeps the destination", async () => {
202
+ await mountControlled();
203
+ await open("Travel");
204
+ assert.equal(
205
+ byAriaLabel("Move to Travel")?.getAttribute("aria-selected"),
206
+ "true",
207
+ );
208
+ await open("Travel");
209
+ assert.equal(byAriaLabel("Move to Hotels"), null);
210
+ assert.equal(
211
+ byAriaLabel("Move to Travel")?.getAttribute("aria-selected"),
212
+ "true",
213
+ );
214
+ assert.equal(
215
+ byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
216
+ "false",
217
+ );
218
+ });
219
+
220
+ it("opens the current folder without ever picking it", async () => {
221
+ const selected: string[] = [];
222
+ await mount({ onSelect: (id) => selected.push(id) });
223
+ await click(byAriaLabel("Inbox (current folder)"));
224
+ assert.deepEqual(selected, []);
225
+ assert.equal(
226
+ byAriaLabel("Inbox (current folder)")?.getAttribute("aria-expanded"),
227
+ "true",
228
+ );
229
+ });
230
+ });
231
+
133
232
  describe("create form anchoring", () => {
134
- it("opens under the row whose affordance was pressed", async () => {
135
- await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
233
+ it("offers the action at the end of an opened folder's children", async () => {
234
+ await mount({ onCreateFolder: resolving });
235
+ assert.equal(byAriaLabel("New folder inside Travel"), null);
236
+ await open("Travel");
237
+ assert.ok(byAriaLabel("New folder inside Travel"));
238
+ assert.equal(byAriaLabel("New folder inside Hotels"), null);
239
+ });
240
+
241
+ it("opens the form under the action it was pressed from", async () => {
242
+ await mount({ onCreateFolder: resolving });
243
+ await open("Travel");
136
244
  await click(byAriaLabel("New folder inside Travel"));
137
- assert.ok(rowOf("Travel")?.querySelector("input"));
138
- assert.equal(rowOf("Hotels")?.querySelector("input"), null);
245
+ assert.ok(createBlockOf("Travel")?.querySelector("input"));
246
+ assert.equal(rowOf("Travel")?.querySelector("input"), null);
139
247
  });
140
248
 
141
249
  it("states the parent as fixed text rather than a second choice", async () => {
142
- await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
250
+ await mount({ onCreateFolder: resolving });
251
+ await open("Travel");
143
252
  await click(byAriaLabel("New folder inside Travel"));
144
- assert.match(rowOf("Travel")?.textContent ?? "", /Inside\s*Travel/);
253
+ assert.match(createBlockOf("Travel")?.textContent ?? "", /Inside\s*Travel/);
145
254
  assert.equal(container.querySelector("select"), null);
146
255
  });
147
256
 
148
- it("moves the form when another row opens it", async () => {
149
- await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
257
+ it("moves the form when the action inside another folder opens it", async () => {
258
+ await mount({ onCreateFolder: resolving });
259
+ await open("Travel", "Hotels");
150
260
  await click(byAriaLabel("New folder inside Travel"));
151
261
  await click(byAriaLabel("New folder inside Hotels"));
152
- assert.equal(rowOf("Travel")?.querySelector("input"), null);
153
- assert.ok(rowOf("Hotels")?.querySelector("input"));
262
+ assert.equal(createBlockOf("Travel")?.querySelector("input"), null);
263
+ assert.ok(createBlockOf("Hotels")?.querySelector("input"));
154
264
  });
155
265
 
156
- it("creates at top level from the row above the tree", async () => {
157
- await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
158
- await click(byText("New folder"));
159
- assert.equal(rowOf("Travel")?.querySelector("input"), null);
266
+ it("creates at top level from the action pinned above the tree", async () => {
267
+ await mount({ onCreateFolder: resolving });
268
+ await click(byAriaLabel("New folder"));
160
269
  assert.ok(nameField());
161
270
  assert.match(container.textContent ?? "", /Inside\s*Top level/);
162
271
  });
163
272
 
164
273
  it("offers a subfolder inside the current folder", async () => {
165
- await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
274
+ await mount({ onCreateFolder: resolving });
275
+ await click(byAriaLabel("Inbox (current folder)"));
166
276
  assert.ok(byAriaLabel("New folder inside Inbox"));
167
277
  });
168
278
 
169
279
  it("closes on Cancel", async () => {
170
- await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
280
+ await mount({ onCreateFolder: resolving });
281
+ await open("Travel");
171
282
  await click(byAriaLabel("New folder inside Travel"));
172
283
  await click(byText("Cancel"));
173
284
  assert.equal(nameField(), null);
@@ -175,7 +286,7 @@ describe("create form anchoring", () => {
175
286
  });
176
287
 
177
288
  describe("create wait", () => {
178
- it("passes the row's path as the parent and selects what comes back", async () => {
289
+ it("passes the opened folder's path as the parent and selects what comes back", async () => {
179
290
  const calls: Array<[string, string]> = [];
180
291
  const selected: string[] = [];
181
292
  await mount({
@@ -185,11 +296,12 @@ describe("create wait", () => {
185
296
  return Promise.resolve(created(name, parentPath));
186
297
  },
187
298
  });
299
+ await open("Travel");
188
300
  await click(byAriaLabel("New folder inside Travel"));
189
301
  await typeName("Hotels 2");
190
302
  await click(byText("Create folder"));
191
303
  assert.deepEqual(calls, [["Hotels 2", "Travel"]]);
192
- assert.deepEqual(selected, ["made"]);
304
+ assert.deepEqual(selected, ["travel", "made"]);
193
305
  assert.equal(nameField(), null);
194
306
  });
195
307
 
@@ -201,6 +313,7 @@ describe("create wait", () => {
201
313
  return new Promise<FolderTreeNode>(() => undefined);
202
314
  },
203
315
  });
316
+ await open("Travel");
204
317
  await click(byAriaLabel("New folder inside Travel"));
205
318
  await typeName("Hotels 2");
206
319
  await click(byText("Create folder"));
@@ -216,12 +329,13 @@ describe("create wait", () => {
216
329
  onCreateFolder: () =>
217
330
  Promise.reject(new Error("The mail server refused that name.")),
218
331
  });
332
+ await open("Travel");
219
333
  await click(byAriaLabel("New folder inside Travel"));
220
334
  await typeName("Hotels 2");
221
335
  await click(byText("Create folder"));
222
336
  const alert = container.querySelector('[role="alert"]');
223
337
  assert.equal(alert?.textContent, "The mail server refused that name.");
224
- assert.ok(rowOf("Travel")?.querySelector("input"));
338
+ assert.ok(createBlockOf("Travel")?.querySelector("input"));
225
339
  });
226
340
 
227
341
  it("says what is missing instead of going dead on an empty name", async () => {
@@ -232,6 +346,7 @@ describe("create wait", () => {
232
346
  return Promise.resolve(created(n, p));
233
347
  },
234
348
  });
349
+ await open("Travel");
235
350
  await click(byAriaLabel("New folder inside Travel"));
236
351
  await click(byText("Create folder"));
237
352
  assert.equal(attempts, 0);
@@ -255,8 +370,8 @@ describe("create wait", () => {
255
370
  });
256
371
  },
257
372
  });
258
- await click(byAriaLabel("New folder inside Travel"));
259
- await typeName("Hotels 2");
373
+ await click(byAriaLabel("New folder"));
374
+ await typeName("Insurance");
260
375
  await click(byText("Create folder"));
261
376
  await act(async () => {
262
377
  root.unmount();
@@ -284,7 +399,8 @@ describe("filter and keyboard", () => {
284
399
  });
285
400
  };
286
401
 
287
- const press = async (target: Element, key: string) => {
402
+ const press = async (target: Element | null | undefined, key: string) => {
403
+ assert.ok(target, "control not rendered");
288
404
  await act(async () => {
289
405
  target.dispatchEvent(
290
406
  new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
@@ -292,71 +408,86 @@ describe("filter and keyboard", () => {
292
408
  });
293
409
  };
294
410
 
295
- it("narrows to the match and keeps its parent as context, not a target", async () => {
411
+ const focused = (): string | null =>
412
+ dom.window.document.activeElement?.getAttribute("aria-label") ?? null;
413
+
414
+ it("opens the ancestors a match hides behind", async () => {
296
415
  await mount({});
416
+ assert.equal(byAriaLabel("Move to Hotels"), null);
297
417
  await typeFilter("hotels");
298
418
  assert.ok(byAriaLabel("Move to Hotels"));
299
419
  assert.equal(byAriaLabel("Move to Travel"), null);
300
420
  assert.ok(byAriaLabel("Travel (containing folder)"));
421
+ assert.equal(
422
+ byAriaLabel("Travel (containing folder)")?.getAttribute("aria-expanded"),
423
+ "true",
424
+ );
301
425
  assert.equal(byAriaLabel("Move to Archive"), null);
302
426
  });
303
427
 
428
+ it("puts the list back the way it was left when the filter clears", async () => {
429
+ await mount({});
430
+ await open("Travel");
431
+ await typeFilter("archive");
432
+ assert.equal(byAriaLabel("Move to Hotels"), null);
433
+ await typeFilter("");
434
+ assert.ok(byAriaLabel("Move to Hotels"));
435
+ });
436
+
304
437
  it("says so when nothing matches", async () => {
305
438
  await mount({});
306
439
  await typeFilter("zzz");
307
440
  assert.match(container.textContent ?? "", /No folders match "zzz"/);
308
441
  });
309
442
 
310
- it("selects the focused row on Enter", async () => {
443
+ it("picks and opens the focused row on Enter", async () => {
311
444
  const selected: string[] = [];
312
445
  await mount({ onSelect: (id) => selected.push(id) });
313
- const first = byAriaLabel("Move to Travel");
314
- assert.ok(first);
315
- await press(first, "Enter");
446
+ await focus(byAriaLabel("Move to Travel"));
447
+ await press(byAriaLabel("Move to Travel"), "Enter");
316
448
  assert.deepEqual(selected, ["travel"]);
449
+ assert.ok(byAriaLabel("Move to Hotels"));
317
450
  });
318
451
 
319
- it("walks the tree with the arrow keys", async () => {
452
+ it("opens on Right and closes on Left without picking anything", async () => {
453
+ const selected: string[] = [];
454
+ await mount({ onSelect: (id) => selected.push(id) });
455
+ await focus(byAriaLabel("Move to Travel"));
456
+ await press(byAriaLabel("Move to Travel"), "ArrowRight");
457
+ assert.ok(byAriaLabel("Move to Hotels"));
458
+ await press(byAriaLabel("Move to Travel"), "ArrowRight");
459
+ assert.equal(focused(), "Move to Hotels");
460
+ await press(byAriaLabel("Move to Hotels"), "ArrowLeft");
461
+ assert.equal(focused(), "Move to Travel");
462
+ await press(byAriaLabel("Move to Travel"), "ArrowLeft");
463
+ assert.equal(byAriaLabel("Move to Hotels"), null);
464
+ assert.deepEqual(selected, []);
465
+ });
466
+
467
+ it("walks only the rows on screen with the arrow keys", async () => {
320
468
  await mount({});
321
- const first = byAriaLabel("Move to Travel");
322
- assert.ok(first);
323
- await press(first, "ArrowDown");
324
- assert.equal(
325
- dom.window.document.activeElement?.getAttribute("aria-label"),
326
- "Move to Hotels",
327
- );
328
- await press(first, "End");
329
- assert.equal(
330
- dom.window.document.activeElement?.getAttribute("aria-label"),
331
- "Move to Archive",
332
- );
333
- await press(first, "Home");
334
- assert.equal(
335
- dom.window.document.activeElement?.getAttribute("aria-label"),
336
- "Move to Travel",
337
- );
469
+ await focus(byAriaLabel("Inbox (current folder)"));
470
+ await press(byAriaLabel("Inbox (current folder)"), "ArrowDown");
471
+ assert.equal(focused(), "Move to Travel");
472
+ await press(byAriaLabel("Move to Travel"), "ArrowDown");
473
+ assert.equal(focused(), "Move to Archive");
474
+ await press(byAriaLabel("Move to Archive"), "Home");
475
+ assert.equal(focused(), "Inbox (current folder)");
476
+ await press(byAriaLabel("Inbox (current folder)"), "End");
477
+ assert.equal(focused(), "Move to Archive");
338
478
  });
339
479
 
340
480
  it("cancels on Escape from the tree and from the filter", async () => {
341
481
  let cancelled = 0;
342
482
  await mount({ onCancel: () => (cancelled += 1) });
343
- const first = byAriaLabel("Move to Travel");
344
- assert.ok(first);
345
- await press(first, "Escape");
346
- const filter = filterField();
347
- assert.ok(filter);
348
- await press(filter, "Escape");
483
+ await press(byAriaLabel("Move to Travel"), "Escape");
484
+ await press(filterField(), "Escape");
349
485
  assert.equal(cancelled, 2);
350
486
  });
351
487
 
352
- it("hands focus from the filter to the first destination on ArrowDown", async () => {
488
+ it("hands focus from the filter to the first row on ArrowDown", async () => {
353
489
  await mount({});
354
- const filter = filterField();
355
- assert.ok(filter);
356
- await press(filter, "ArrowDown");
357
- assert.equal(
358
- dom.window.document.activeElement?.getAttribute("aria-label"),
359
- "Move to Travel",
360
- );
490
+ await press(filterField(), "ArrowDown");
491
+ assert.equal(focused(), "Inbox (current folder)");
361
492
  });
362
493
  });