@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,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
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("
|
|
135
|
-
await mount({ onCreateFolder:
|
|
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(
|
|
138
|
-
assert.equal(rowOf("
|
|
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:
|
|
250
|
+
await mount({ onCreateFolder: resolving });
|
|
251
|
+
await open("Travel");
|
|
143
252
|
await click(byAriaLabel("New folder inside Travel"));
|
|
144
|
-
assert.match(
|
|
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
|
|
149
|
-
await mount({ onCreateFolder:
|
|
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(
|
|
153
|
-
assert.ok(
|
|
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
|
|
157
|
-
await mount({ onCreateFolder:
|
|
158
|
-
await click(
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
259
|
-
await typeName("
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
314
|
-
|
|
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("
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
);
|
|
328
|
-
await press(
|
|
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
|
-
|
|
344
|
-
|
|
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
|
|
488
|
+
it("hands focus from the filter to the first row on ArrowDown", async () => {
|
|
353
489
|
await mount({});
|
|
354
|
-
|
|
355
|
-
assert.
|
|
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
|
});
|