@remit/ui 0.0.67 → 0.0.69
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-manage-actions.render.test.ts +51 -0
- package/src/components/folder-manage-actions.stories.tsx +94 -0
- package/src/components/folder-manage-actions.tsx +54 -0
- package/src/components/folder-manager.render.test.ts +80 -0
- package/src/components/folder-manager.stories.tsx +184 -0
- package/src/components/folder-manager.tsx +77 -0
- package/src/components/folder-rename-dialog.render.test.ts +51 -0
- package/src/components/folder-rename-dialog.stories.tsx +66 -0
- package/src/components/folder-rename-dialog.tsx +95 -0
- package/src/components/folder-row.render.test.ts +88 -0
- package/src/components/folder-row.stories.tsx +118 -0
- package/src/components/folder-row.tsx +156 -0
- package/src/components/folder-tree-picker.create.test.ts +155 -8
- package/src/components/folder-tree-picker.render.test.ts +12 -284
- package/src/components/folder-tree-picker.stories.tsx +20 -7
- package/src/components/folder-tree-picker.tsx +130 -534
- 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 +47 -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
|
@@ -2,24 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import { createElement } from "react";
|
|
4
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;
|
|
5
|
+
import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
|
|
23
6
|
|
|
24
7
|
const node = (
|
|
25
8
|
id: string,
|
|
@@ -37,11 +20,6 @@ const folders: FolderTreeNode[] = [
|
|
|
37
20
|
node("trash", "Trash", "Deleted Messages"),
|
|
38
21
|
];
|
|
39
22
|
|
|
40
|
-
const ordered = orderFolderNodes(folders, "/");
|
|
41
|
-
|
|
42
|
-
const paths = (rows: readonly { folder: FolderTreeNode }[]): string[] =>
|
|
43
|
-
rows.map((row) => row.folder.path);
|
|
44
|
-
|
|
45
23
|
/** The opening tag of the element carrying `needle`, attribute order aside. */
|
|
46
24
|
const tagWith = (html: string, needle: string): string => {
|
|
47
25
|
const at = html.indexOf(needle);
|
|
@@ -49,267 +27,6 @@ const tagWith = (html: string, needle: string): string => {
|
|
|
49
27
|
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
|
|
50
28
|
};
|
|
51
29
|
|
|
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
30
|
describe("FolderTreePicker render", () => {
|
|
314
31
|
const render = (props: Partial<Parameters<typeof FolderTreePicker>[0]>) =>
|
|
315
32
|
renderToString(
|
|
@@ -365,6 +82,17 @@ describe("FolderTreePicker render", () => {
|
|
|
365
82
|
assert.doesNotMatch(html, /aria-label="New folder inside/);
|
|
366
83
|
});
|
|
367
84
|
|
|
85
|
+
it("gives the pinned create action the prominent treatment", () => {
|
|
86
|
+
const html = render({
|
|
87
|
+
onCreateFolder: () =>
|
|
88
|
+
Promise.resolve(node("made", "Made", "Travel/Made")),
|
|
89
|
+
});
|
|
90
|
+
assert.match(
|
|
91
|
+
tagWith(html, 'aria-label="New folder"'),
|
|
92
|
+
/data-prominence="prominent"/,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
368
96
|
it("renders the empty state when no folder matches", () => {
|
|
369
97
|
const html = render({ folders: [] });
|
|
370
98
|
assert.match(html, /No folders match/);
|
|
@@ -127,7 +127,10 @@ function Picker({
|
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* The list at rest: top level only, one tap deep into anything, with the pinned
|
|
132
|
+
* "New folder" carrying the kit's dashed add affordance.
|
|
133
|
+
*/
|
|
131
134
|
export const Default: Story = {
|
|
132
135
|
name: "Default (collapsed to top level)",
|
|
133
136
|
render: () => <Picker />,
|
|
@@ -208,7 +211,8 @@ const expand = async (canvasElement: HTMLElement, ...labels: string[]) => {
|
|
|
208
211
|
|
|
209
212
|
/**
|
|
210
213
|
* One tap picks the destination and opens it. The children arrive indented,
|
|
211
|
-
* with "New folder"
|
|
214
|
+
* with a quieter "New folder" at the end of them — the same action as the
|
|
215
|
+
* pinned one, subordinate to the folder it sits in.
|
|
212
216
|
*/
|
|
213
217
|
export const Expanded: Story = {
|
|
214
218
|
name: "A folder opened (children and its New folder)",
|
|
@@ -218,7 +222,10 @@ export const Expanded: Story = {
|
|
|
218
222
|
},
|
|
219
223
|
};
|
|
220
224
|
|
|
221
|
-
/**
|
|
225
|
+
/**
|
|
226
|
+
* One open branch, three levels deep: the ancestors stay open because the
|
|
227
|
+
* folder you opened lives inside them.
|
|
228
|
+
*/
|
|
222
229
|
export const DeepExpansion: Story = {
|
|
223
230
|
name: "Opened three levels deep",
|
|
224
231
|
render: () => <Picker />,
|
|
@@ -250,9 +257,9 @@ export const CreateTopLevel: Story = {
|
|
|
250
257
|
};
|
|
251
258
|
|
|
252
259
|
/**
|
|
253
|
-
* The folder you opened is the answer to "inside where": the
|
|
254
|
-
*
|
|
255
|
-
* choice to make.
|
|
260
|
+
* The folder you opened is the answer to "inside where": the form takes the
|
|
261
|
+
* place of the action it came from, among that folder's children, and states
|
|
262
|
+
* the parent as text — so there is no second choice to make.
|
|
256
263
|
*/
|
|
257
264
|
export const CreateSubfolder: Story = {
|
|
258
265
|
name: "Create a folder inside an opened one",
|
|
@@ -353,10 +360,16 @@ function WizardFolderStep() {
|
|
|
353
360
|
);
|
|
354
361
|
}
|
|
355
362
|
|
|
356
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* The pattern at the size it is used: the wizard's folder step on a phone, with
|
|
365
|
+
* a folder open so both create actions are on screen at once.
|
|
366
|
+
*/
|
|
357
367
|
export const PhoneWizardStep: Story = {
|
|
358
368
|
name: "Phone — wizard folder step",
|
|
359
369
|
parameters: { layout: "fullscreen" },
|
|
360
370
|
globals: { viewport: { value: "mobile" } },
|
|
361
371
|
render: () => <WizardFolderStep />,
|
|
372
|
+
play: async ({ canvasElement }) => {
|
|
373
|
+
await expand(canvasElement, "Travel");
|
|
374
|
+
},
|
|
362
375
|
};
|