@remit/ui 0.0.66 → 0.0.68
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 +1 -1
- package/src/components/folder-row.render.test.ts +81 -0
- package/src/components/folder-row.stories.tsx +118 -0
- package/src/components/folder-row.tsx +147 -0
- package/src/components/folder-tree-picker.create.test.ts +341 -63
- package/src/components/folder-tree-picker.render.test.ts +21 -155
- package/src/components/folder-tree-picker.stories.tsx +56 -10
- package/src/components/folder-tree-picker.tsx +210 -340
- package/src/components/new-folder-action.render.test.ts +44 -0
- package/src/components/new-folder-action.stories.tsx +92 -0
- package/src/components/new-folder-action.tsx +59 -0
- package/src/components/new-folder-form.render.test.ts +64 -0
- package/src/components/new-folder-form.stories.tsx +74 -0
- package/src/components/new-folder-form.tsx +126 -0
- package/src/index.ts +33 -0
- package/src/lib/folder-tree-focus.test.ts +98 -0
- package/src/lib/folder-tree-focus.ts +52 -0
- package/src/lib/folder-tree.test.ts +281 -0
- package/src/lib/folder-tree.ts +201 -0
|
@@ -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 {
|
|
@@ -22,14 +24,18 @@ const folders: FolderTreeNode[] = [
|
|
|
22
24
|
{ id: "inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
23
25
|
{ id: "travel", label: "Travel", path: "Travel" },
|
|
24
26
|
{ id: "hotels", label: "Hotels", path: "Travel/Hotels" },
|
|
27
|
+
{ id: "receipts", label: "Receipts", path: "Travel/Hotels/Receipts" },
|
|
28
|
+
{ id: "flights", label: "Flights", path: "Travel/Flights" },
|
|
25
29
|
{ id: "archive", label: "Archive", path: "Archive" },
|
|
26
30
|
];
|
|
27
31
|
|
|
28
32
|
let dom: JSDOM;
|
|
33
|
+
const scrolledIntoView: Element[] = [];
|
|
29
34
|
let container: HTMLElement;
|
|
30
35
|
let root: Root;
|
|
31
36
|
let act: typeof reactAct;
|
|
32
37
|
let createElement: typeof reactCreateElement;
|
|
38
|
+
let useState: typeof reactUseState;
|
|
33
39
|
let createRoot: typeof reactCreateRoot;
|
|
34
40
|
let FolderTreePicker: typeof FolderTreePickerType;
|
|
35
41
|
|
|
@@ -51,10 +57,17 @@ before(async () => {
|
|
|
51
57
|
(
|
|
52
58
|
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
53
59
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
60
|
+
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
|
|
61
|
+
configurable: true,
|
|
62
|
+
value: function scrollIntoView(this: Element) {
|
|
63
|
+
scrolledIntoView.push(this);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
54
66
|
|
|
55
67
|
const react = await import("react");
|
|
56
68
|
act = react.act;
|
|
57
69
|
createElement = react.createElement;
|
|
70
|
+
useState = react.useState;
|
|
58
71
|
({ createRoot } = await import("react-dom/client"));
|
|
59
72
|
({ FolderTreePicker } = await import("./folder-tree-picker.js"));
|
|
60
73
|
});
|
|
@@ -64,6 +77,7 @@ after(() => {
|
|
|
64
77
|
});
|
|
65
78
|
|
|
66
79
|
beforeEach(() => {
|
|
80
|
+
scrolledIntoView.length = 0;
|
|
67
81
|
container = dom.window.document.createElement("div");
|
|
68
82
|
dom.window.document.body.append(container);
|
|
69
83
|
root = createRoot(container);
|
|
@@ -76,9 +90,9 @@ afterEach(async () => {
|
|
|
76
90
|
container.remove();
|
|
77
91
|
});
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
) => {
|
|
93
|
+
type PickerProps = Partial<Parameters<typeof FolderTreePickerType>[0]>;
|
|
94
|
+
|
|
95
|
+
const mount = async (props: PickerProps = {}) => {
|
|
82
96
|
await act(async () => {
|
|
83
97
|
root.render(
|
|
84
98
|
createElement(FolderTreePicker, {
|
|
@@ -90,6 +104,22 @@ const mount = async (
|
|
|
90
104
|
});
|
|
91
105
|
};
|
|
92
106
|
|
|
107
|
+
/** Holds the chosen destination the way the app does, so a tap can be undone. */
|
|
108
|
+
const mountControlled = async (props: PickerProps = {}) => {
|
|
109
|
+
const Controlled = () => {
|
|
110
|
+
const [selected, setSelected] = useState<string>();
|
|
111
|
+
return createElement(FolderTreePicker, {
|
|
112
|
+
folders,
|
|
113
|
+
selectedId: selected,
|
|
114
|
+
onSelect: setSelected,
|
|
115
|
+
...props,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
await act(async () => {
|
|
119
|
+
root.render(createElement(Controlled));
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
93
123
|
const click = async (element: Element | null | undefined) => {
|
|
94
124
|
assert.ok(element, "control not rendered");
|
|
95
125
|
await act(async () => {
|
|
@@ -97,6 +127,13 @@ const click = async (element: Element | null | undefined) => {
|
|
|
97
127
|
});
|
|
98
128
|
};
|
|
99
129
|
|
|
130
|
+
const focus = async (element: Element | null | undefined) => {
|
|
131
|
+
assert.ok(element, "control not rendered");
|
|
132
|
+
await act(async () => {
|
|
133
|
+
(element as HTMLElement).focus();
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
|
|
100
137
|
const byAriaLabel = (label: string): HTMLElement | null =>
|
|
101
138
|
container.querySelector(`[aria-label="${label}"]`);
|
|
102
139
|
|
|
@@ -121,6 +158,20 @@ const typeName = async (value: string) => {
|
|
|
121
158
|
});
|
|
122
159
|
};
|
|
123
160
|
|
|
161
|
+
const open = async (...labels: string[]) => {
|
|
162
|
+
for (const label of labels) {
|
|
163
|
+
await click(byAriaLabel(`Move to ${label}`));
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The block a folder's create action lives in — and, while the form is open,
|
|
169
|
+
* the form that took its place. Keyed on the anchor rather than the action, so
|
|
170
|
+
* it still resolves once the action has given way.
|
|
171
|
+
*/
|
|
172
|
+
const createBlockOf = (anchor: string): Element | null =>
|
|
173
|
+
container.querySelector(`[data-create-anchor="${anchor}"]`);
|
|
174
|
+
|
|
124
175
|
const rowOf = (label: string): Element | null =>
|
|
125
176
|
byAriaLabel(`Move to ${label}`)?.closest('[role="none"]') ?? null;
|
|
126
177
|
|
|
@@ -130,52 +181,259 @@ const created = (name: string, parentPath: string): FolderTreeNode => ({
|
|
|
130
181
|
path: parentPath ? `${parentPath}/${name}` : name,
|
|
131
182
|
});
|
|
132
183
|
|
|
184
|
+
const resolving = (name: string, parentPath: string) =>
|
|
185
|
+
Promise.resolve(created(name, parentPath));
|
|
186
|
+
|
|
187
|
+
describe("opening folders", () => {
|
|
188
|
+
it("starts at the top level with everything closed", async () => {
|
|
189
|
+
await mount({});
|
|
190
|
+
assert.ok(byAriaLabel("Move to Travel"));
|
|
191
|
+
assert.ok(byAriaLabel("Move to Archive"));
|
|
192
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
193
|
+
assert.equal(
|
|
194
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
195
|
+
"false",
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("picks the destination and opens it in one tap", async () => {
|
|
200
|
+
const selected: string[] = [];
|
|
201
|
+
await mount({ onSelect: (id) => selected.push(id) });
|
|
202
|
+
await open("Travel");
|
|
203
|
+
assert.deepEqual(selected, ["travel"]);
|
|
204
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
205
|
+
assert.equal(
|
|
206
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
207
|
+
"true",
|
|
208
|
+
);
|
|
209
|
+
assert.equal(
|
|
210
|
+
byAriaLabel("Move to Hotels")?.getAttribute("aria-level"),
|
|
211
|
+
"2",
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("closes on a second tap and keeps the destination", async () => {
|
|
216
|
+
await mountControlled();
|
|
217
|
+
await open("Travel");
|
|
218
|
+
assert.equal(
|
|
219
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-selected"),
|
|
220
|
+
"true",
|
|
221
|
+
);
|
|
222
|
+
await open("Travel");
|
|
223
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
224
|
+
assert.equal(
|
|
225
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-selected"),
|
|
226
|
+
"true",
|
|
227
|
+
);
|
|
228
|
+
assert.equal(
|
|
229
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
230
|
+
"false",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("opens the current folder without ever picking it", async () => {
|
|
235
|
+
const selected: string[] = [];
|
|
236
|
+
await mount({ onSelect: (id) => selected.push(id) });
|
|
237
|
+
await click(byAriaLabel("Inbox (current folder)"));
|
|
238
|
+
assert.deepEqual(selected, []);
|
|
239
|
+
assert.equal(
|
|
240
|
+
byAriaLabel("Inbox (current folder)")?.getAttribute("aria-expanded"),
|
|
241
|
+
"true",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("one open branch", () => {
|
|
247
|
+
it("closes the folder that was open when another opens", async () => {
|
|
248
|
+
await mount({});
|
|
249
|
+
await open("Travel");
|
|
250
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
251
|
+
await open("Archive");
|
|
252
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
253
|
+
assert.equal(
|
|
254
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
255
|
+
"false",
|
|
256
|
+
);
|
|
257
|
+
assert.equal(
|
|
258
|
+
byAriaLabel("Move to Archive")?.getAttribute("aria-expanded"),
|
|
259
|
+
"true",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("keeps the ancestors of the folder being opened open", async () => {
|
|
264
|
+
await mount({});
|
|
265
|
+
await open("Travel", "Hotels");
|
|
266
|
+
assert.ok(byAriaLabel("Move to Receipts"));
|
|
267
|
+
assert.equal(
|
|
268
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
269
|
+
"true",
|
|
270
|
+
);
|
|
271
|
+
assert.equal(
|
|
272
|
+
byAriaLabel("Move to Hotels")?.getAttribute("aria-expanded"),
|
|
273
|
+
"true",
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("closes a sibling branch without closing the folder holding both", async () => {
|
|
278
|
+
await mount({});
|
|
279
|
+
await open("Travel", "Hotels");
|
|
280
|
+
await open("Flights");
|
|
281
|
+
assert.equal(byAriaLabel("Move to Receipts"), null);
|
|
282
|
+
assert.equal(
|
|
283
|
+
byAriaLabel("Move to Hotels")?.getAttribute("aria-expanded"),
|
|
284
|
+
"false",
|
|
285
|
+
);
|
|
286
|
+
assert.equal(
|
|
287
|
+
byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
|
|
288
|
+
"true",
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("leaves one create action inside the branch that stayed open", async () => {
|
|
293
|
+
await mount({ onCreateFolder: resolving });
|
|
294
|
+
await open("Travel", "Hotels");
|
|
295
|
+
await open("Archive");
|
|
296
|
+
assert.equal(byAriaLabel("New folder inside Travel"), null);
|
|
297
|
+
assert.equal(byAriaLabel("New folder inside Hotels"), null);
|
|
298
|
+
assert.ok(byAriaLabel("New folder inside Archive"));
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("create action prominence", () => {
|
|
303
|
+
it("keeps the pinned action prominent and the nested one quiet", async () => {
|
|
304
|
+
await mount({ onCreateFolder: resolving });
|
|
305
|
+
assert.equal(byAriaLabel("New folder")?.dataset.prominence, "prominent");
|
|
306
|
+
await open("Travel");
|
|
307
|
+
assert.equal(
|
|
308
|
+
byAriaLabel("New folder inside Travel")?.dataset.prominence,
|
|
309
|
+
"quiet",
|
|
310
|
+
);
|
|
311
|
+
assert.equal(byAriaLabel("New folder")?.dataset.prominence, "prominent");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("stays the same action either way", async () => {
|
|
315
|
+
await mount({ onCreateFolder: resolving });
|
|
316
|
+
await open("Travel");
|
|
317
|
+
const pinned = byAriaLabel("New folder");
|
|
318
|
+
const nested = byAriaLabel("New folder inside Travel");
|
|
319
|
+
assert.equal(pinned?.tagName, nested?.tagName);
|
|
320
|
+
assert.equal(pinned?.textContent, nested?.textContent);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
133
324
|
describe("create form anchoring", () => {
|
|
134
|
-
it("
|
|
135
|
-
await mount({ onCreateFolder:
|
|
325
|
+
it("offers the action at the end of an opened folder's children", async () => {
|
|
326
|
+
await mount({ onCreateFolder: resolving });
|
|
327
|
+
assert.equal(byAriaLabel("New folder inside Travel"), null);
|
|
328
|
+
await open("Travel");
|
|
329
|
+
assert.ok(byAriaLabel("New folder inside Travel"));
|
|
330
|
+
assert.equal(byAriaLabel("New folder inside Hotels"), null);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("opens the form under the action it was pressed from", async () => {
|
|
334
|
+
await mount({ onCreateFolder: resolving });
|
|
335
|
+
await open("Travel");
|
|
136
336
|
await click(byAriaLabel("New folder inside Travel"));
|
|
137
|
-
assert.ok(
|
|
138
|
-
assert.equal(rowOf("
|
|
337
|
+
assert.ok(createBlockOf("travel")?.querySelector("input"));
|
|
338
|
+
assert.equal(rowOf("Travel")?.querySelector("input"), null);
|
|
139
339
|
});
|
|
140
340
|
|
|
141
341
|
it("states the parent as fixed text rather than a second choice", async () => {
|
|
142
|
-
await mount({ onCreateFolder:
|
|
342
|
+
await mount({ onCreateFolder: resolving });
|
|
343
|
+
await open("Travel");
|
|
143
344
|
await click(byAriaLabel("New folder inside Travel"));
|
|
144
|
-
assert.match(
|
|
345
|
+
assert.match(createBlockOf("travel")?.textContent ?? "", /Inside\s*Travel/);
|
|
145
346
|
assert.equal(container.querySelector("select"), null);
|
|
146
347
|
});
|
|
147
348
|
|
|
148
|
-
it("moves the form when another
|
|
149
|
-
await mount({ onCreateFolder:
|
|
349
|
+
it("moves the form when the action inside another folder opens it", async () => {
|
|
350
|
+
await mount({ onCreateFolder: resolving });
|
|
351
|
+
await open("Travel", "Hotels");
|
|
150
352
|
await click(byAriaLabel("New folder inside Travel"));
|
|
151
353
|
await click(byAriaLabel("New folder inside Hotels"));
|
|
152
|
-
assert.equal(
|
|
153
|
-
assert.ok(
|
|
354
|
+
assert.equal(createBlockOf("travel")?.querySelector("input"), null);
|
|
355
|
+
assert.ok(createBlockOf("hotels")?.querySelector("input"));
|
|
154
356
|
});
|
|
155
357
|
|
|
156
|
-
it("creates at top level from the
|
|
157
|
-
await mount({ onCreateFolder:
|
|
158
|
-
await click(
|
|
159
|
-
assert.equal(rowOf("Travel")?.querySelector("input"), null);
|
|
358
|
+
it("creates at top level from the action pinned above the tree", async () => {
|
|
359
|
+
await mount({ onCreateFolder: resolving });
|
|
360
|
+
await click(byAriaLabel("New folder"));
|
|
160
361
|
assert.ok(nameField());
|
|
161
362
|
assert.match(container.textContent ?? "", /Inside\s*Top level/);
|
|
162
363
|
});
|
|
163
364
|
|
|
164
365
|
it("offers a subfolder inside the current folder", async () => {
|
|
165
|
-
await mount({ onCreateFolder:
|
|
366
|
+
await mount({ onCreateFolder: resolving });
|
|
367
|
+
await click(byAriaLabel("Inbox (current folder)"));
|
|
166
368
|
assert.ok(byAriaLabel("New folder inside Inbox"));
|
|
167
369
|
});
|
|
168
370
|
|
|
169
371
|
it("closes on Cancel", async () => {
|
|
170
|
-
await mount({ onCreateFolder:
|
|
372
|
+
await mount({ onCreateFolder: resolving });
|
|
373
|
+
await open("Travel");
|
|
171
374
|
await click(byAriaLabel("New folder inside Travel"));
|
|
172
375
|
await click(byText("Cancel"));
|
|
173
376
|
assert.equal(nameField(), null);
|
|
174
377
|
});
|
|
175
378
|
});
|
|
176
379
|
|
|
380
|
+
describe("the form takes the place of the action that opened it", () => {
|
|
381
|
+
it("replaces the pinned action", async () => {
|
|
382
|
+
await mount({ onCreateFolder: resolving });
|
|
383
|
+
await click(byAriaLabel("New folder"));
|
|
384
|
+
assert.equal(byAriaLabel("New folder"), null);
|
|
385
|
+
assert.ok(createBlockOf("top")?.querySelector("input"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("replaces a nested action and leaves the others alone", async () => {
|
|
389
|
+
await mount({ onCreateFolder: resolving });
|
|
390
|
+
await open("Travel", "Hotels");
|
|
391
|
+
await click(byAriaLabel("New folder inside Hotels"));
|
|
392
|
+
assert.equal(byAriaLabel("New folder inside Hotels"), null);
|
|
393
|
+
assert.ok(byAriaLabel("New folder inside Travel"));
|
|
394
|
+
assert.ok(byAriaLabel("New folder"));
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("puts the action back once the form closes", async () => {
|
|
398
|
+
await mount({ onCreateFolder: resolving });
|
|
399
|
+
await open("Travel");
|
|
400
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
401
|
+
await click(byText("Cancel"));
|
|
402
|
+
assert.ok(byAriaLabel("New folder inside Travel"));
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("an opened form is reachable", () => {
|
|
407
|
+
it("brings the whole form into view, buttons included", async () => {
|
|
408
|
+
await mount({ onCreateFolder: resolving });
|
|
409
|
+
await open("Archive");
|
|
410
|
+
await click(byAriaLabel("New folder inside Archive"));
|
|
411
|
+
const scrolled = scrolledIntoView.at(-1);
|
|
412
|
+
assert.ok(scrolled, "nothing was scrolled into view");
|
|
413
|
+
assert.ok(scrolled.contains(nameField()));
|
|
414
|
+
assert.ok(scrolled.contains(byText("Create folder") ?? null));
|
|
415
|
+
assert.ok(scrolled.contains(byText("Cancel") ?? null));
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("scrolls the list rather than the page", async () => {
|
|
419
|
+
await mount({ onCreateFolder: resolving });
|
|
420
|
+
await open("Archive");
|
|
421
|
+
await click(byAriaLabel("New folder inside Archive"));
|
|
422
|
+
const tree = container.querySelector('[role="tree"]');
|
|
423
|
+
assert.ok(tree?.contains(scrolledIntoView.at(-1) ?? null));
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("keeps room below the last row for a form opened from it", async () => {
|
|
427
|
+
await mount({ onCreateFolder: resolving });
|
|
428
|
+
assert.match(
|
|
429
|
+
container.querySelector('[role="tree"]')?.className ?? "",
|
|
430
|
+
/\bpb-\d/,
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
177
435
|
describe("create wait", () => {
|
|
178
|
-
it("passes the
|
|
436
|
+
it("passes the opened folder's path as the parent and selects what comes back", async () => {
|
|
179
437
|
const calls: Array<[string, string]> = [];
|
|
180
438
|
const selected: string[] = [];
|
|
181
439
|
await mount({
|
|
@@ -185,11 +443,12 @@ describe("create wait", () => {
|
|
|
185
443
|
return Promise.resolve(created(name, parentPath));
|
|
186
444
|
},
|
|
187
445
|
});
|
|
446
|
+
await open("Travel");
|
|
188
447
|
await click(byAriaLabel("New folder inside Travel"));
|
|
189
448
|
await typeName("Hotels 2");
|
|
190
449
|
await click(byText("Create folder"));
|
|
191
450
|
assert.deepEqual(calls, [["Hotels 2", "Travel"]]);
|
|
192
|
-
assert.deepEqual(selected, ["made"]);
|
|
451
|
+
assert.deepEqual(selected, ["travel", "made"]);
|
|
193
452
|
assert.equal(nameField(), null);
|
|
194
453
|
});
|
|
195
454
|
|
|
@@ -201,6 +460,7 @@ describe("create wait", () => {
|
|
|
201
460
|
return new Promise<FolderTreeNode>(() => undefined);
|
|
202
461
|
},
|
|
203
462
|
});
|
|
463
|
+
await open("Travel");
|
|
204
464
|
await click(byAriaLabel("New folder inside Travel"));
|
|
205
465
|
await typeName("Hotels 2");
|
|
206
466
|
await click(byText("Create folder"));
|
|
@@ -216,12 +476,13 @@ describe("create wait", () => {
|
|
|
216
476
|
onCreateFolder: () =>
|
|
217
477
|
Promise.reject(new Error("The mail server refused that name.")),
|
|
218
478
|
});
|
|
479
|
+
await open("Travel");
|
|
219
480
|
await click(byAriaLabel("New folder inside Travel"));
|
|
220
481
|
await typeName("Hotels 2");
|
|
221
482
|
await click(byText("Create folder"));
|
|
222
483
|
const alert = container.querySelector('[role="alert"]');
|
|
223
484
|
assert.equal(alert?.textContent, "The mail server refused that name.");
|
|
224
|
-
assert.ok(
|
|
485
|
+
assert.ok(createBlockOf("travel")?.querySelector("input"));
|
|
225
486
|
});
|
|
226
487
|
|
|
227
488
|
it("says what is missing instead of going dead on an empty name", async () => {
|
|
@@ -232,6 +493,7 @@ describe("create wait", () => {
|
|
|
232
493
|
return Promise.resolve(created(n, p));
|
|
233
494
|
},
|
|
234
495
|
});
|
|
496
|
+
await open("Travel");
|
|
235
497
|
await click(byAriaLabel("New folder inside Travel"));
|
|
236
498
|
await click(byText("Create folder"));
|
|
237
499
|
assert.equal(attempts, 0);
|
|
@@ -255,8 +517,8 @@ describe("create wait", () => {
|
|
|
255
517
|
});
|
|
256
518
|
},
|
|
257
519
|
});
|
|
258
|
-
await click(byAriaLabel("New folder
|
|
259
|
-
await typeName("
|
|
520
|
+
await click(byAriaLabel("New folder"));
|
|
521
|
+
await typeName("Insurance");
|
|
260
522
|
await click(byText("Create folder"));
|
|
261
523
|
await act(async () => {
|
|
262
524
|
root.unmount();
|
|
@@ -284,7 +546,8 @@ describe("filter and keyboard", () => {
|
|
|
284
546
|
});
|
|
285
547
|
};
|
|
286
548
|
|
|
287
|
-
const press = async (target: Element, key: string) => {
|
|
549
|
+
const press = async (target: Element | null | undefined, key: string) => {
|
|
550
|
+
assert.ok(target, "control not rendered");
|
|
288
551
|
await act(async () => {
|
|
289
552
|
target.dispatchEvent(
|
|
290
553
|
new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
@@ -292,71 +555,86 @@ describe("filter and keyboard", () => {
|
|
|
292
555
|
});
|
|
293
556
|
};
|
|
294
557
|
|
|
295
|
-
|
|
558
|
+
const focused = (): string | null =>
|
|
559
|
+
dom.window.document.activeElement?.getAttribute("aria-label") ?? null;
|
|
560
|
+
|
|
561
|
+
it("opens the ancestors a match hides behind", async () => {
|
|
296
562
|
await mount({});
|
|
563
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
297
564
|
await typeFilter("hotels");
|
|
298
565
|
assert.ok(byAriaLabel("Move to Hotels"));
|
|
299
566
|
assert.equal(byAriaLabel("Move to Travel"), null);
|
|
300
567
|
assert.ok(byAriaLabel("Travel (containing folder)"));
|
|
568
|
+
assert.equal(
|
|
569
|
+
byAriaLabel("Travel (containing folder)")?.getAttribute("aria-expanded"),
|
|
570
|
+
"true",
|
|
571
|
+
);
|
|
301
572
|
assert.equal(byAriaLabel("Move to Archive"), null);
|
|
302
573
|
});
|
|
303
574
|
|
|
575
|
+
it("puts the list back the way it was left when the filter clears", async () => {
|
|
576
|
+
await mount({});
|
|
577
|
+
await open("Travel");
|
|
578
|
+
await typeFilter("archive");
|
|
579
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
580
|
+
await typeFilter("");
|
|
581
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
582
|
+
});
|
|
583
|
+
|
|
304
584
|
it("says so when nothing matches", async () => {
|
|
305
585
|
await mount({});
|
|
306
586
|
await typeFilter("zzz");
|
|
307
587
|
assert.match(container.textContent ?? "", /No folders match "zzz"/);
|
|
308
588
|
});
|
|
309
589
|
|
|
310
|
-
it("
|
|
590
|
+
it("picks and opens the focused row on Enter", async () => {
|
|
311
591
|
const selected: string[] = [];
|
|
312
592
|
await mount({ onSelect: (id) => selected.push(id) });
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
await press(first, "Enter");
|
|
593
|
+
await focus(byAriaLabel("Move to Travel"));
|
|
594
|
+
await press(byAriaLabel("Move to Travel"), "Enter");
|
|
316
595
|
assert.deepEqual(selected, ["travel"]);
|
|
596
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("opens on Right and closes on Left without picking anything", async () => {
|
|
600
|
+
const selected: string[] = [];
|
|
601
|
+
await mount({ onSelect: (id) => selected.push(id) });
|
|
602
|
+
await focus(byAriaLabel("Move to Travel"));
|
|
603
|
+
await press(byAriaLabel("Move to Travel"), "ArrowRight");
|
|
604
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
605
|
+
await press(byAriaLabel("Move to Travel"), "ArrowRight");
|
|
606
|
+
assert.equal(focused(), "Move to Hotels");
|
|
607
|
+
await press(byAriaLabel("Move to Hotels"), "ArrowLeft");
|
|
608
|
+
assert.equal(focused(), "Move to Travel");
|
|
609
|
+
await press(byAriaLabel("Move to Travel"), "ArrowLeft");
|
|
610
|
+
assert.equal(byAriaLabel("Move to Hotels"), null);
|
|
611
|
+
assert.deepEqual(selected, []);
|
|
317
612
|
});
|
|
318
613
|
|
|
319
|
-
it("walks the
|
|
614
|
+
it("walks only the rows on screen with the arrow keys", async () => {
|
|
320
615
|
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
|
-
);
|
|
616
|
+
await focus(byAriaLabel("Inbox (current folder)"));
|
|
617
|
+
await press(byAriaLabel("Inbox (current folder)"), "ArrowDown");
|
|
618
|
+
assert.equal(focused(), "Move to Travel");
|
|
619
|
+
await press(byAriaLabel("Move to Travel"), "ArrowDown");
|
|
620
|
+
assert.equal(focused(), "Move to Archive");
|
|
621
|
+
await press(byAriaLabel("Move to Archive"), "Home");
|
|
622
|
+
assert.equal(focused(), "Inbox (current folder)");
|
|
623
|
+
await press(byAriaLabel("Inbox (current folder)"), "End");
|
|
624
|
+
assert.equal(focused(), "Move to Archive");
|
|
338
625
|
});
|
|
339
626
|
|
|
340
627
|
it("cancels on Escape from the tree and from the filter", async () => {
|
|
341
628
|
let cancelled = 0;
|
|
342
629
|
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");
|
|
630
|
+
await press(byAriaLabel("Move to Travel"), "Escape");
|
|
631
|
+
await press(filterField(), "Escape");
|
|
349
632
|
assert.equal(cancelled, 2);
|
|
350
633
|
});
|
|
351
634
|
|
|
352
|
-
it("hands focus from the filter to the first
|
|
635
|
+
it("hands focus from the filter to the first row on ArrowDown", async () => {
|
|
353
636
|
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
|
-
);
|
|
637
|
+
await press(filterField(), "ArrowDown");
|
|
638
|
+
assert.equal(focused(), "Inbox (current folder)");
|
|
361
639
|
});
|
|
362
640
|
});
|