@remit/ui 0.0.65 → 0.0.66
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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline create form: where it opens, what it says the parent is, and how it
|
|
3
|
+
* behaves while the mail server is confirming the folder. Mounted against jsdom
|
|
4
|
+
* for the anchoring, the field state and the async resolve. React is imported
|
|
5
|
+
* after the jsdom globals are installed so the controlled value tracker binds to
|
|
6
|
+
* jsdom's prototypes.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
10
|
+
import type { JSDOM } from "jsdom";
|
|
11
|
+
import type {
|
|
12
|
+
act as reactAct,
|
|
13
|
+
createElement as reactCreateElement,
|
|
14
|
+
} from "react";
|
|
15
|
+
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
16
|
+
import type {
|
|
17
|
+
FolderTreeNode,
|
|
18
|
+
FolderTreePicker as FolderTreePickerType,
|
|
19
|
+
} from "./folder-tree-picker.js";
|
|
20
|
+
|
|
21
|
+
const folders: FolderTreeNode[] = [
|
|
22
|
+
{ id: "inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
23
|
+
{ id: "travel", label: "Travel", path: "Travel" },
|
|
24
|
+
{ id: "hotels", label: "Hotels", path: "Travel/Hotels" },
|
|
25
|
+
{ id: "archive", label: "Archive", path: "Archive" },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
let dom: JSDOM;
|
|
29
|
+
let container: HTMLElement;
|
|
30
|
+
let root: Root;
|
|
31
|
+
let act: typeof reactAct;
|
|
32
|
+
let createElement: typeof reactCreateElement;
|
|
33
|
+
let createRoot: typeof reactCreateRoot;
|
|
34
|
+
let FolderTreePicker: typeof FolderTreePickerType;
|
|
35
|
+
|
|
36
|
+
before(async () => {
|
|
37
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
38
|
+
dom = new JSDOMCtor(
|
|
39
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
40
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
41
|
+
);
|
|
42
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
43
|
+
globalThis.document = dom.window.document;
|
|
44
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
45
|
+
globalThis.Element = dom.window.Element;
|
|
46
|
+
globalThis.Event = dom.window.Event;
|
|
47
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
48
|
+
value: dom.window.navigator,
|
|
49
|
+
configurable: true,
|
|
50
|
+
});
|
|
51
|
+
(
|
|
52
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
53
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
54
|
+
|
|
55
|
+
const react = await import("react");
|
|
56
|
+
act = react.act;
|
|
57
|
+
createElement = react.createElement;
|
|
58
|
+
({ createRoot } = await import("react-dom/client"));
|
|
59
|
+
({ FolderTreePicker } = await import("./folder-tree-picker.js"));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
after(() => {
|
|
63
|
+
dom.window.close();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
container = dom.window.document.createElement("div");
|
|
68
|
+
dom.window.document.body.append(container);
|
|
69
|
+
root = createRoot(container);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
await act(async () => {
|
|
74
|
+
root.unmount();
|
|
75
|
+
});
|
|
76
|
+
container.remove();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const mount = async (
|
|
80
|
+
props: Partial<Parameters<typeof FolderTreePickerType>[0]> = {},
|
|
81
|
+
) => {
|
|
82
|
+
await act(async () => {
|
|
83
|
+
root.render(
|
|
84
|
+
createElement(FolderTreePicker, {
|
|
85
|
+
folders,
|
|
86
|
+
onSelect: () => {},
|
|
87
|
+
...props,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const click = async (element: Element | null | undefined) => {
|
|
94
|
+
assert.ok(element, "control not rendered");
|
|
95
|
+
await act(async () => {
|
|
96
|
+
(element as HTMLElement).click();
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const byAriaLabel = (label: string): HTMLElement | null =>
|
|
101
|
+
container.querySelector(`[aria-label="${label}"]`);
|
|
102
|
+
|
|
103
|
+
const byText = (label: string): HTMLButtonElement | undefined =>
|
|
104
|
+
Array.from(container.querySelectorAll("button")).find(
|
|
105
|
+
(button) => button.textContent?.trim() === label,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const nameField = (): HTMLInputElement | null =>
|
|
109
|
+
container.querySelector('input:not([type="search"])');
|
|
110
|
+
|
|
111
|
+
const typeName = async (value: string) => {
|
|
112
|
+
const input = nameField();
|
|
113
|
+
assert.ok(input, "name field not rendered");
|
|
114
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
115
|
+
dom.window.HTMLInputElement.prototype,
|
|
116
|
+
"value",
|
|
117
|
+
)?.set;
|
|
118
|
+
await act(async () => {
|
|
119
|
+
setter?.call(input, value);
|
|
120
|
+
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
121
|
+
});
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const rowOf = (label: string): Element | null =>
|
|
125
|
+
byAriaLabel(`Move to ${label}`)?.closest('[role="none"]') ?? null;
|
|
126
|
+
|
|
127
|
+
const created = (name: string, parentPath: string): FolderTreeNode => ({
|
|
128
|
+
id: "made",
|
|
129
|
+
label: name,
|
|
130
|
+
path: parentPath ? `${parentPath}/${name}` : name,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
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)) });
|
|
136
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
137
|
+
assert.ok(rowOf("Travel")?.querySelector("input"));
|
|
138
|
+
assert.equal(rowOf("Hotels")?.querySelector("input"), null);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("states the parent as fixed text rather than a second choice", async () => {
|
|
142
|
+
await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
|
|
143
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
144
|
+
assert.match(rowOf("Travel")?.textContent ?? "", /Inside\s*Travel/);
|
|
145
|
+
assert.equal(container.querySelector("select"), null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("moves the form when another row opens it", async () => {
|
|
149
|
+
await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
|
|
150
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
151
|
+
await click(byAriaLabel("New folder inside Hotels"));
|
|
152
|
+
assert.equal(rowOf("Travel")?.querySelector("input"), null);
|
|
153
|
+
assert.ok(rowOf("Hotels")?.querySelector("input"));
|
|
154
|
+
});
|
|
155
|
+
|
|
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);
|
|
160
|
+
assert.ok(nameField());
|
|
161
|
+
assert.match(container.textContent ?? "", /Inside\s*Top level/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("offers a subfolder inside the current folder", async () => {
|
|
165
|
+
await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
|
|
166
|
+
assert.ok(byAriaLabel("New folder inside Inbox"));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("closes on Cancel", async () => {
|
|
170
|
+
await mount({ onCreateFolder: (n, p) => Promise.resolve(created(n, p)) });
|
|
171
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
172
|
+
await click(byText("Cancel"));
|
|
173
|
+
assert.equal(nameField(), null);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("create wait", () => {
|
|
178
|
+
it("passes the row's path as the parent and selects what comes back", async () => {
|
|
179
|
+
const calls: Array<[string, string]> = [];
|
|
180
|
+
const selected: string[] = [];
|
|
181
|
+
await mount({
|
|
182
|
+
onSelect: (id) => selected.push(id),
|
|
183
|
+
onCreateFolder: (name, parentPath) => {
|
|
184
|
+
calls.push([name, parentPath]);
|
|
185
|
+
return Promise.resolve(created(name, parentPath));
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
189
|
+
await typeName("Hotels 2");
|
|
190
|
+
await click(byText("Create folder"));
|
|
191
|
+
assert.deepEqual(calls, [["Hotels 2", "Travel"]]);
|
|
192
|
+
assert.deepEqual(selected, ["made"]);
|
|
193
|
+
assert.equal(nameField(), null);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("shows the wait and refuses a second submit while it runs", async () => {
|
|
197
|
+
let attempts = 0;
|
|
198
|
+
await mount({
|
|
199
|
+
onCreateFolder: () => {
|
|
200
|
+
attempts += 1;
|
|
201
|
+
return new Promise<FolderTreeNode>(() => undefined);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
205
|
+
await typeName("Hotels 2");
|
|
206
|
+
await click(byText("Create folder"));
|
|
207
|
+
const pending = byText("Creating folder…");
|
|
208
|
+
assert.ok(pending);
|
|
209
|
+
assert.equal(pending.disabled, true);
|
|
210
|
+
await click(pending);
|
|
211
|
+
assert.equal(attempts, 1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("states a failure where it happened and keeps the form open", async () => {
|
|
215
|
+
await mount({
|
|
216
|
+
onCreateFolder: () =>
|
|
217
|
+
Promise.reject(new Error("The mail server refused that name.")),
|
|
218
|
+
});
|
|
219
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
220
|
+
await typeName("Hotels 2");
|
|
221
|
+
await click(byText("Create folder"));
|
|
222
|
+
const alert = container.querySelector('[role="alert"]');
|
|
223
|
+
assert.equal(alert?.textContent, "The mail server refused that name.");
|
|
224
|
+
assert.ok(rowOf("Travel")?.querySelector("input"));
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("says what is missing instead of going dead on an empty name", async () => {
|
|
228
|
+
let attempts = 0;
|
|
229
|
+
await mount({
|
|
230
|
+
onCreateFolder: (n, p) => {
|
|
231
|
+
attempts += 1;
|
|
232
|
+
return Promise.resolve(created(n, p));
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
236
|
+
await click(byText("Create folder"));
|
|
237
|
+
assert.equal(attempts, 0);
|
|
238
|
+
assert.match(
|
|
239
|
+
container.querySelector('[role="alert"]')?.textContent ?? "",
|
|
240
|
+
/Give the folder a name/,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("aborts the wait on unmount so a late confirmation selects nothing", async () => {
|
|
245
|
+
let signal: AbortSignal | undefined;
|
|
246
|
+
const selected: string[] = [];
|
|
247
|
+
await mount({
|
|
248
|
+
onSelect: (id) => selected.push(id),
|
|
249
|
+
onCreateFolder: (_name, _parentPath, abortSignal) => {
|
|
250
|
+
signal = abortSignal;
|
|
251
|
+
return new Promise<FolderTreeNode>((_resolve, reject) => {
|
|
252
|
+
abortSignal?.addEventListener("abort", () =>
|
|
253
|
+
reject(new DOMException("Aborted", "AbortError")),
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
await click(byAriaLabel("New folder inside Travel"));
|
|
259
|
+
await typeName("Hotels 2");
|
|
260
|
+
await click(byText("Create folder"));
|
|
261
|
+
await act(async () => {
|
|
262
|
+
root.unmount();
|
|
263
|
+
});
|
|
264
|
+
assert.equal(signal?.aborted, true);
|
|
265
|
+
assert.deepEqual(selected, []);
|
|
266
|
+
root = createRoot(container);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("filter and keyboard", () => {
|
|
271
|
+
const filterField = (): HTMLInputElement | null =>
|
|
272
|
+
container.querySelector('input[type="search"]');
|
|
273
|
+
|
|
274
|
+
const typeFilter = async (value: string) => {
|
|
275
|
+
const input = filterField();
|
|
276
|
+
assert.ok(input);
|
|
277
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
278
|
+
dom.window.HTMLInputElement.prototype,
|
|
279
|
+
"value",
|
|
280
|
+
)?.set;
|
|
281
|
+
await act(async () => {
|
|
282
|
+
setter?.call(input, value);
|
|
283
|
+
input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const press = async (target: Element, key: string) => {
|
|
288
|
+
await act(async () => {
|
|
289
|
+
target.dispatchEvent(
|
|
290
|
+
new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
it("narrows to the match and keeps its parent as context, not a target", async () => {
|
|
296
|
+
await mount({});
|
|
297
|
+
await typeFilter("hotels");
|
|
298
|
+
assert.ok(byAriaLabel("Move to Hotels"));
|
|
299
|
+
assert.equal(byAriaLabel("Move to Travel"), null);
|
|
300
|
+
assert.ok(byAriaLabel("Travel (containing folder)"));
|
|
301
|
+
assert.equal(byAriaLabel("Move to Archive"), null);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("says so when nothing matches", async () => {
|
|
305
|
+
await mount({});
|
|
306
|
+
await typeFilter("zzz");
|
|
307
|
+
assert.match(container.textContent ?? "", /No folders match "zzz"/);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("selects the focused row on Enter", async () => {
|
|
311
|
+
const selected: string[] = [];
|
|
312
|
+
await mount({ onSelect: (id) => selected.push(id) });
|
|
313
|
+
const first = byAriaLabel("Move to Travel");
|
|
314
|
+
assert.ok(first);
|
|
315
|
+
await press(first, "Enter");
|
|
316
|
+
assert.deepEqual(selected, ["travel"]);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("walks the tree with the arrow keys", async () => {
|
|
320
|
+
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
|
+
);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("cancels on Escape from the tree and from the filter", async () => {
|
|
341
|
+
let cancelled = 0;
|
|
342
|
+
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");
|
|
349
|
+
assert.equal(cancelled, 2);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("hands focus from the filter to the first destination on ArrowDown", async () => {
|
|
353
|
+
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
|
+
);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
type FolderTreeNode,
|
|
7
|
+
FolderTreePicker,
|
|
8
|
+
folderTreePickerInternals,
|
|
9
|
+
} from "./folder-tree-picker.js";
|
|
10
|
+
|
|
11
|
+
const {
|
|
12
|
+
orderFolderNodes,
|
|
13
|
+
filterFolderTree,
|
|
14
|
+
matchesQuery,
|
|
15
|
+
findFirstSelectable,
|
|
16
|
+
findLastSelectable,
|
|
17
|
+
findNextSelectable,
|
|
18
|
+
} = folderTreePickerInternals;
|
|
19
|
+
|
|
20
|
+
const node = (
|
|
21
|
+
id: string,
|
|
22
|
+
label: string,
|
|
23
|
+
path: string,
|
|
24
|
+
isCurrent?: boolean,
|
|
25
|
+
): FolderTreeNode => ({ id, label, path, isCurrent });
|
|
26
|
+
|
|
27
|
+
const folders: FolderTreeNode[] = [
|
|
28
|
+
node("inbox", "Inbox", "INBOX", true),
|
|
29
|
+
node("hotels", "Hotels", "Travel/Hotels"),
|
|
30
|
+
node("archive", "Archive", "Archive"),
|
|
31
|
+
node("travel", "Travel", "Travel"),
|
|
32
|
+
node("receipts", "Receipts", "Travel/Hotels/Receipts"),
|
|
33
|
+
node("trash", "Trash", "Deleted Messages"),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const paths = (rows: readonly { folder: FolderTreeNode }[]): string[] =>
|
|
37
|
+
rows.map((row) => row.folder.path);
|
|
38
|
+
|
|
39
|
+
/** The opening tag of the element carrying `needle`, attribute order aside. */
|
|
40
|
+
const tagWith = (html: string, needle: string): string => {
|
|
41
|
+
const at = html.indexOf(needle);
|
|
42
|
+
assert.notEqual(at, -1, `not rendered: ${needle}`);
|
|
43
|
+
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("folder ordering", () => {
|
|
47
|
+
it("puts every child straight after its parent", () => {
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
orderFolderNodes(folders, "/").map((folder) => folder.path),
|
|
50
|
+
[
|
|
51
|
+
"INBOX",
|
|
52
|
+
"Archive",
|
|
53
|
+
"Travel",
|
|
54
|
+
"Travel/Hotels",
|
|
55
|
+
"Travel/Hotels/Receipts",
|
|
56
|
+
"Deleted Messages",
|
|
57
|
+
],
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders a folder whose parent is absent as a root", () => {
|
|
62
|
+
const orphaned = [
|
|
63
|
+
node("archive", "Archive", "Archive"),
|
|
64
|
+
node("apollo", "Apollo", "Work/Projects/Apollo"),
|
|
65
|
+
];
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
orderFolderNodes(orphaned, "/").map((folder) => folder.path),
|
|
68
|
+
["Archive", "Work/Projects/Apollo"],
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("nests on the provider's separator", () => {
|
|
73
|
+
const dotted = [
|
|
74
|
+
node("child", "Hotels", "Travel.Hotels"),
|
|
75
|
+
node("other", "Archive", "Archive"),
|
|
76
|
+
node("parent", "Travel", "Travel"),
|
|
77
|
+
];
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
orderFolderNodes(dotted, ".").map((folder) => folder.path),
|
|
80
|
+
["Archive", "Travel", "Travel.Hotels"],
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("filtering", () => {
|
|
86
|
+
const ordered = orderFolderNodes(folders, "/");
|
|
87
|
+
|
|
88
|
+
it("matches on the label", () => {
|
|
89
|
+
assert.equal(matchesQuery(node("a", "Archive", "Archive"), "arch"), true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("matches on the path, so a role-labelled folder is still findable", () => {
|
|
93
|
+
assert.equal(
|
|
94
|
+
matchesQuery(node("t", "Trash", "Deleted Messages"), "deleted"),
|
|
95
|
+
true,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("keeps the ancestors of a match on screen", () => {
|
|
100
|
+
const rows = filterFolderTree(ordered, "receipts", "/");
|
|
101
|
+
assert.deepEqual(paths(rows), [
|
|
102
|
+
"Travel",
|
|
103
|
+
"Travel/Hotels",
|
|
104
|
+
"Travel/Hotels/Receipts",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("marks an ancestor kept for context as no match of its own", () => {
|
|
109
|
+
const rows = filterFolderTree(ordered, "receipts", "/");
|
|
110
|
+
assert.deepEqual(
|
|
111
|
+
rows.map((row) => row.context),
|
|
112
|
+
[true, true, false],
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("keeps a matching ancestor a match", () => {
|
|
117
|
+
const rows = filterFolderTree(ordered, "travel", "/");
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
rows.map((row) => row.context),
|
|
120
|
+
[false, false, false],
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("carries the depth the row indents by", () => {
|
|
125
|
+
const rows = filterFolderTree(ordered, "", "/");
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
rows.map((row) => row.depth),
|
|
128
|
+
[0, 0, 0, 1, 2, 0],
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("narrows to nothing when the query matches no folder", () => {
|
|
133
|
+
assert.deepEqual(filterFolderTree(ordered, "zzz", "/"), []);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("roving focus", () => {
|
|
138
|
+
const rows = filterFolderTree(
|
|
139
|
+
orderFolderNodes(folders, "/"),
|
|
140
|
+
"receipts",
|
|
141
|
+
"/",
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
it("skips context rows when finding the first destination", () => {
|
|
145
|
+
assert.equal(findFirstSelectable(rows), 2);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("finds the last destination", () => {
|
|
149
|
+
assert.equal(findLastSelectable(rows), 2);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("skips the current folder", () => {
|
|
153
|
+
const all = filterFolderTree(orderFolderNodes(folders, "/"), "", "/");
|
|
154
|
+
assert.equal(findFirstSelectable(all), 1);
|
|
155
|
+
assert.equal(findNextSelectable(all, 5, 1), 1);
|
|
156
|
+
assert.equal(findNextSelectable(all, 1, -1), 5);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns -1 when nothing is selectable", () => {
|
|
160
|
+
const only = filterFolderTree(
|
|
161
|
+
orderFolderNodes([node("inbox", "Inbox", "INBOX", true)], "/"),
|
|
162
|
+
"",
|
|
163
|
+
"/",
|
|
164
|
+
);
|
|
165
|
+
assert.equal(findFirstSelectable(only), -1);
|
|
166
|
+
assert.equal(findLastSelectable(only), -1);
|
|
167
|
+
assert.equal(findNextSelectable(only, 0, 1), -1);
|
|
168
|
+
assert.equal(findNextSelectable([], 0, 1), -1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("FolderTreePicker render", () => {
|
|
173
|
+
const render = (props: Partial<Parameters<typeof FolderTreePicker>[0]>) =>
|
|
174
|
+
renderToString(
|
|
175
|
+
createElement(FolderTreePicker, {
|
|
176
|
+
folders,
|
|
177
|
+
onSelect: () => {},
|
|
178
|
+
...props,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
it("renders the folders as a tree with a level per row", () => {
|
|
183
|
+
const html = render({});
|
|
184
|
+
assert.match(html, /role="tree"/);
|
|
185
|
+
assert.equal(html.match(/role="treeitem"/g)?.length, 6);
|
|
186
|
+
assert.match(
|
|
187
|
+
tagWith(html, 'aria-label="Move to Hotels"'),
|
|
188
|
+
/aria-level="2"/,
|
|
189
|
+
);
|
|
190
|
+
assert.match(
|
|
191
|
+
tagWith(html, 'aria-label="Move to Receipts"'),
|
|
192
|
+
/aria-level="3"/,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("marks the current folder as a marker, never a disabled control", () => {
|
|
197
|
+
const html = render({});
|
|
198
|
+
assert.match(html, /aria-label="Inbox \(current folder\)"/);
|
|
199
|
+
assert.match(html, /aria-current="true"/);
|
|
200
|
+
assert.doesNotMatch(html, /disabled/);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("checks the chosen row and leaves the rest unselected", () => {
|
|
204
|
+
const html = render({ selectedId: "archive" });
|
|
205
|
+
assert.match(
|
|
206
|
+
tagWith(html, 'aria-label="Move to Archive"'),
|
|
207
|
+
/aria-selected="true"/,
|
|
208
|
+
);
|
|
209
|
+
assert.match(
|
|
210
|
+
tagWith(html, 'aria-label="Move to Travel"'),
|
|
211
|
+
/aria-selected="false"/,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("offers no create affordance without onCreateFolder", () => {
|
|
216
|
+
const html = render({});
|
|
217
|
+
assert.doesNotMatch(html, /New folder/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("offers a create row at the top and one per folder", () => {
|
|
221
|
+
const html = render({
|
|
222
|
+
onCreateFolder: () =>
|
|
223
|
+
Promise.resolve(node("made", "Made", "Travel/Made")),
|
|
224
|
+
});
|
|
225
|
+
assert.match(html, /New folder<\/button>/);
|
|
226
|
+
assert.match(html, /aria-label="New folder inside Travel"/);
|
|
227
|
+
assert.match(html, /aria-label="New folder inside Inbox"/);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("renders the empty state when no folder matches", () => {
|
|
231
|
+
const html = render({ folders: [] });
|
|
232
|
+
assert.match(html, /No folders match/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("applies caller-supplied labels", () => {
|
|
236
|
+
const html = render({
|
|
237
|
+
labels: { optionLabel: (label) => `Verplaats naar ${label}` },
|
|
238
|
+
});
|
|
239
|
+
assert.match(html, /aria-label="Verplaats naar Archive"/);
|
|
240
|
+
});
|
|
241
|
+
});
|