@remit/ui 0.0.65 → 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.
@@ -0,0 +1,379 @@
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
+ collapseFolderTree,
15
+ queryExpandedPaths,
16
+ withCreateRows,
17
+ matchesQuery,
18
+ findFirstFocusable,
19
+ findLastFocusable,
20
+ findNextFocusable,
21
+ findParentRow,
22
+ } = folderTreePickerInternals;
23
+
24
+ const node = (
25
+ id: string,
26
+ label: string,
27
+ path: string,
28
+ isCurrent?: boolean,
29
+ ): FolderTreeNode => ({ id, label, path, isCurrent });
30
+
31
+ const folders: FolderTreeNode[] = [
32
+ node("inbox", "Inbox", "INBOX", true),
33
+ node("hotels", "Hotels", "Travel/Hotels"),
34
+ node("archive", "Archive", "Archive"),
35
+ node("travel", "Travel", "Travel"),
36
+ node("receipts", "Receipts", "Travel/Hotels/Receipts"),
37
+ node("trash", "Trash", "Deleted Messages"),
38
+ ];
39
+
40
+ const ordered = orderFolderNodes(folders, "/");
41
+
42
+ const paths = (rows: readonly { folder: FolderTreeNode }[]): string[] =>
43
+ rows.map((row) => row.folder.path);
44
+
45
+ /** The opening tag of the element carrying `needle`, attribute order aside. */
46
+ const tagWith = (html: string, needle: string): string => {
47
+ const at = html.indexOf(needle);
48
+ assert.notEqual(at, -1, `not rendered: ${needle}`);
49
+ return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
50
+ };
51
+
52
+ describe("folder ordering", () => {
53
+ it("puts every child straight after its parent", () => {
54
+ assert.deepEqual(
55
+ orderFolderNodes(folders, "/").map((folder) => folder.path),
56
+ [
57
+ "INBOX",
58
+ "Archive",
59
+ "Travel",
60
+ "Travel/Hotels",
61
+ "Travel/Hotels/Receipts",
62
+ "Deleted Messages",
63
+ ],
64
+ );
65
+ });
66
+
67
+ it("renders a folder whose parent is absent as a root", () => {
68
+ const orphaned = [
69
+ node("archive", "Archive", "Archive"),
70
+ node("apollo", "Apollo", "Work/Projects/Apollo"),
71
+ ];
72
+ assert.deepEqual(
73
+ orderFolderNodes(orphaned, "/").map((folder) => folder.path),
74
+ ["Archive", "Work/Projects/Apollo"],
75
+ );
76
+ });
77
+
78
+ it("nests on the provider's separator", () => {
79
+ const dotted = [
80
+ node("child", "Hotels", "Travel.Hotels"),
81
+ node("other", "Archive", "Archive"),
82
+ node("parent", "Travel", "Travel"),
83
+ ];
84
+ assert.deepEqual(
85
+ orderFolderNodes(dotted, ".").map((folder) => folder.path),
86
+ ["Archive", "Travel", "Travel.Hotels"],
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("collapsing", () => {
92
+ it("shows the top level and nothing under it", () => {
93
+ const rows = collapseFolderTree(ordered, new Set(), "/");
94
+ assert.deepEqual(paths(rows), [
95
+ "INBOX",
96
+ "Archive",
97
+ "Travel",
98
+ "Deleted Messages",
99
+ ]);
100
+ });
101
+
102
+ it("reveals only the children of what is open", () => {
103
+ const rows = collapseFolderTree(ordered, new Set(["Travel"]), "/");
104
+ assert.deepEqual(paths(rows), [
105
+ "INBOX",
106
+ "Archive",
107
+ "Travel",
108
+ "Travel/Hotels",
109
+ "Deleted Messages",
110
+ ]);
111
+ });
112
+
113
+ it("needs every ancestor open to reach a grandchild", () => {
114
+ assert.deepEqual(
115
+ paths(collapseFolderTree(ordered, new Set(["Travel/Hotels"]), "/")),
116
+ ["INBOX", "Archive", "Travel", "Deleted Messages"],
117
+ );
118
+ assert.deepEqual(
119
+ paths(
120
+ collapseFolderTree(ordered, new Set(["Travel", "Travel/Hotels"]), "/"),
121
+ ),
122
+ [
123
+ "INBOX",
124
+ "Archive",
125
+ "Travel",
126
+ "Travel/Hotels",
127
+ "Travel/Hotels/Receipts",
128
+ "Deleted Messages",
129
+ ],
130
+ );
131
+ });
132
+
133
+ it("marks what is open on the row", () => {
134
+ const rows = collapseFolderTree(ordered, new Set(["Travel"]), "/");
135
+ assert.deepEqual(
136
+ rows.map((row) => row.expanded),
137
+ [false, false, true, false, false],
138
+ );
139
+ });
140
+
141
+ it("shows a folder whose parent is absent from the list", () => {
142
+ const orphaned = orderFolderNodes(
143
+ [
144
+ node("archive", "Archive", "Archive"),
145
+ node("apollo", "Apollo", "Work/Projects/Apollo"),
146
+ ],
147
+ "/",
148
+ );
149
+ assert.deepEqual(paths(collapseFolderTree(orphaned, new Set(), "/")), [
150
+ "Archive",
151
+ "Work/Projects/Apollo",
152
+ ]);
153
+ });
154
+ });
155
+
156
+ describe("filtering", () => {
157
+ it("matches on the label", () => {
158
+ assert.equal(matchesQuery(node("a", "Archive", "Archive"), "arch"), true);
159
+ });
160
+
161
+ it("matches on the path, so a role-labelled folder is still findable", () => {
162
+ assert.equal(
163
+ matchesQuery(node("t", "Trash", "Deleted Messages"), "deleted"),
164
+ true,
165
+ );
166
+ });
167
+
168
+ it("keeps the ancestors of a match on screen", () => {
169
+ const rows = filterFolderTree(ordered, "receipts", "/");
170
+ assert.deepEqual(paths(rows), [
171
+ "Travel",
172
+ "Travel/Hotels",
173
+ "Travel/Hotels/Receipts",
174
+ ]);
175
+ });
176
+
177
+ it("marks an ancestor kept for context as no match of its own", () => {
178
+ const rows = filterFolderTree(ordered, "receipts", "/");
179
+ assert.deepEqual(
180
+ rows.map((row) => row.context),
181
+ [true, true, false],
182
+ );
183
+ });
184
+
185
+ it("keeps a matching ancestor a match", () => {
186
+ const rows = filterFolderTree(ordered, "travel", "/");
187
+ assert.deepEqual(
188
+ rows.map((row) => row.context),
189
+ [false, false, false],
190
+ );
191
+ });
192
+
193
+ it("opens the ancestors a match hides behind", () => {
194
+ assert.deepEqual([...queryExpandedPaths(ordered, "receipts", "/")].sort(), [
195
+ "Travel",
196
+ "Travel/Hotels",
197
+ ]);
198
+ });
199
+
200
+ it("opens nothing on an empty query, so the list stays as it was", () => {
201
+ assert.equal(queryExpandedPaths(ordered, "", "/").size, 0);
202
+ });
203
+
204
+ it("carries the depth the row indents by", () => {
205
+ const rows = filterFolderTree(ordered, "", "/");
206
+ assert.deepEqual(
207
+ rows.map((row) => row.depth),
208
+ [0, 0, 0, 1, 2, 0],
209
+ );
210
+ });
211
+
212
+ it("narrows to nothing when the query matches no folder", () => {
213
+ assert.deepEqual(filterFolderTree(ordered, "zzz", "/"), []);
214
+ });
215
+ });
216
+
217
+ describe("create rows", () => {
218
+ const kinds = (expanded: string[]) =>
219
+ withCreateRows(
220
+ collapseFolderTree(ordered, new Set(expanded), "/"),
221
+ "/",
222
+ ).map((entry) =>
223
+ entry.kind === "create"
224
+ ? `new inside ${entry.parent.path} @${entry.depth}`
225
+ : entry.row.folder.path,
226
+ );
227
+
228
+ it("offers none while everything is closed", () => {
229
+ assert.deepEqual(kinds([]), [
230
+ "INBOX",
231
+ "Archive",
232
+ "Travel",
233
+ "Deleted Messages",
234
+ ]);
235
+ });
236
+
237
+ it("puts the action after the children of the folder it belongs to", () => {
238
+ assert.deepEqual(kinds(["Travel"]), [
239
+ "INBOX",
240
+ "Archive",
241
+ "Travel",
242
+ "Travel/Hotels",
243
+ "new inside Travel @1",
244
+ "Deleted Messages",
245
+ ]);
246
+ });
247
+
248
+ it("closes the deepest branch first when several are open", () => {
249
+ assert.deepEqual(kinds(["Travel", "Travel/Hotels"]), [
250
+ "INBOX",
251
+ "Archive",
252
+ "Travel",
253
+ "Travel/Hotels",
254
+ "Travel/Hotels/Receipts",
255
+ "new inside Travel/Hotels @2",
256
+ "new inside Travel @1",
257
+ "Deleted Messages",
258
+ ]);
259
+ });
260
+
261
+ it("offers the action on an open folder with no children", () => {
262
+ assert.deepEqual(kinds(["Archive"]), [
263
+ "INBOX",
264
+ "Archive",
265
+ "new inside Archive @1",
266
+ "Travel",
267
+ "Deleted Messages",
268
+ ]);
269
+ });
270
+ });
271
+
272
+ describe("roving focus", () => {
273
+ const rows = filterFolderTree(ordered, "receipts", "/");
274
+
275
+ it("skips context rows, which are branches rather than folders to open", () => {
276
+ assert.equal(findFirstFocusable(rows), 2);
277
+ assert.equal(findLastFocusable(rows), 2);
278
+ });
279
+
280
+ it("reaches the current folder, which cannot be picked but can be opened", () => {
281
+ const all = collapseFolderTree(ordered, new Set(), "/");
282
+ assert.equal(findFirstFocusable(all), 0);
283
+ assert.equal(findNextFocusable(all, 3, 1), 0);
284
+ assert.equal(findNextFocusable(all, 0, -1), 3);
285
+ });
286
+
287
+ it("walks only what is on screen, so closed children are skipped", () => {
288
+ const closed = collapseFolderTree(ordered, new Set(), "/");
289
+ assert.equal(
290
+ closed[findNextFocusable(closed, 2, 1)]?.folder.path,
291
+ "Deleted Messages",
292
+ );
293
+ const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
294
+ assert.equal(
295
+ open[findNextFocusable(open, 2, 1)]?.folder.path,
296
+ "Travel/Hotels",
297
+ );
298
+ });
299
+
300
+ it("finds the parent to move focus to when a row closes", () => {
301
+ const open = collapseFolderTree(ordered, new Set(["Travel"]), "/");
302
+ assert.equal(findParentRow(open, 3, "/"), 2);
303
+ assert.equal(findParentRow(open, 2, "/"), -1);
304
+ });
305
+
306
+ it("returns -1 when there is nothing to walk", () => {
307
+ assert.equal(findFirstFocusable([]), -1);
308
+ assert.equal(findLastFocusable([]), -1);
309
+ assert.equal(findNextFocusable([], 0, 1), -1);
310
+ });
311
+ });
312
+
313
+ describe("FolderTreePicker render", () => {
314
+ const render = (props: Partial<Parameters<typeof FolderTreePicker>[0]>) =>
315
+ renderToString(
316
+ createElement(FolderTreePicker, {
317
+ folders,
318
+ onSelect: () => {},
319
+ ...props,
320
+ }),
321
+ );
322
+
323
+ it("starts at the top level, with every row closed and openable", () => {
324
+ const html = render({});
325
+ assert.match(html, /role="tree"/);
326
+ assert.equal(html.match(/role="treeitem"/g)?.length, 4);
327
+ assert.equal(html.match(/aria-expanded="false"/g)?.length, 4);
328
+ assert.doesNotMatch(html, /aria-label="Move to Hotels"/);
329
+ assert.match(
330
+ tagWith(html, 'aria-label="Move to Travel"'),
331
+ /aria-level="1"/,
332
+ );
333
+ });
334
+
335
+ it("marks the current folder as a marker, never a disabled control", () => {
336
+ const html = render({});
337
+ assert.match(html, /aria-label="Inbox \(current folder\)"/);
338
+ assert.match(html, /aria-current="true"/);
339
+ assert.doesNotMatch(html, /disabled/);
340
+ });
341
+
342
+ it("checks the chosen row and leaves the rest unselected", () => {
343
+ const html = render({ selectedId: "archive" });
344
+ assert.match(
345
+ tagWith(html, 'aria-label="Move to Archive"'),
346
+ /aria-selected="true"/,
347
+ );
348
+ assert.match(
349
+ tagWith(html, 'aria-label="Move to Travel"'),
350
+ /aria-selected="false"/,
351
+ );
352
+ });
353
+
354
+ it("offers no create affordance without onCreateFolder", () => {
355
+ const html = render({});
356
+ assert.doesNotMatch(html, /New folder/);
357
+ });
358
+
359
+ it("pins one create action above the tree and none inside a closed list", () => {
360
+ const html = render({
361
+ onCreateFolder: () =>
362
+ Promise.resolve(node("made", "Made", "Travel/Made")),
363
+ });
364
+ assert.equal(html.match(/aria-label="New folder"/g)?.length, 1);
365
+ assert.doesNotMatch(html, /aria-label="New folder inside/);
366
+ });
367
+
368
+ it("renders the empty state when no folder matches", () => {
369
+ const html = render({ folders: [] });
370
+ assert.match(html, /No folders match/);
371
+ });
372
+
373
+ it("applies caller-supplied labels", () => {
374
+ const html = render({
375
+ labels: { optionLabel: (label) => `Verplaats naar ${label}` },
376
+ });
377
+ assert.match(html, /aria-label="Verplaats naar Archive"/);
378
+ });
379
+ });