@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
|
@@ -2,20 +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
|
-
matchesQuery,
|
|
15
|
-
findFirstSelectable,
|
|
16
|
-
findLastSelectable,
|
|
17
|
-
findNextSelectable,
|
|
18
|
-
} = folderTreePickerInternals;
|
|
5
|
+
import { type FolderTreeNode, FolderTreePicker } from "./folder-tree-picker.js";
|
|
19
6
|
|
|
20
7
|
const node = (
|
|
21
8
|
id: string,
|
|
@@ -33,9 +20,6 @@ const folders: FolderTreeNode[] = [
|
|
|
33
20
|
node("trash", "Trash", "Deleted Messages"),
|
|
34
21
|
];
|
|
35
22
|
|
|
36
|
-
const paths = (rows: readonly { folder: FolderTreeNode }[]): string[] =>
|
|
37
|
-
rows.map((row) => row.folder.path);
|
|
38
|
-
|
|
39
23
|
/** The opening tag of the element carrying `needle`, attribute order aside. */
|
|
40
24
|
const tagWith = (html: string, needle: string): string => {
|
|
41
25
|
const at = html.indexOf(needle);
|
|
@@ -43,132 +27,6 @@ const tagWith = (html: string, needle: string): string => {
|
|
|
43
27
|
return html.slice(html.lastIndexOf("<", at), html.indexOf(">", at) + 1);
|
|
44
28
|
};
|
|
45
29
|
|
|
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
30
|
describe("FolderTreePicker render", () => {
|
|
173
31
|
const render = (props: Partial<Parameters<typeof FolderTreePicker>[0]>) =>
|
|
174
32
|
renderToString(
|
|
@@ -179,17 +37,15 @@ describe("FolderTreePicker render", () => {
|
|
|
179
37
|
}),
|
|
180
38
|
);
|
|
181
39
|
|
|
182
|
-
it("
|
|
40
|
+
it("starts at the top level, with every row closed and openable", () => {
|
|
183
41
|
const html = render({});
|
|
184
42
|
assert.match(html, /role="tree"/);
|
|
185
|
-
assert.equal(html.match(/role="treeitem"/g)?.length,
|
|
186
|
-
assert.match(
|
|
187
|
-
|
|
188
|
-
/aria-level="2"/,
|
|
189
|
-
);
|
|
43
|
+
assert.equal(html.match(/role="treeitem"/g)?.length, 4);
|
|
44
|
+
assert.equal(html.match(/aria-expanded="false"/g)?.length, 4);
|
|
45
|
+
assert.doesNotMatch(html, /aria-label="Move to Hotels"/);
|
|
190
46
|
assert.match(
|
|
191
|
-
tagWith(html, 'aria-label="Move to
|
|
192
|
-
/aria-level="
|
|
47
|
+
tagWith(html, 'aria-label="Move to Travel"'),
|
|
48
|
+
/aria-level="1"/,
|
|
193
49
|
);
|
|
194
50
|
});
|
|
195
51
|
|
|
@@ -217,14 +73,24 @@ describe("FolderTreePicker render", () => {
|
|
|
217
73
|
assert.doesNotMatch(html, /New folder/);
|
|
218
74
|
});
|
|
219
75
|
|
|
220
|
-
it("
|
|
76
|
+
it("pins one create action above the tree and none inside a closed list", () => {
|
|
221
77
|
const html = render({
|
|
222
78
|
onCreateFolder: () =>
|
|
223
79
|
Promise.resolve(node("made", "Made", "Travel/Made")),
|
|
224
80
|
});
|
|
225
|
-
assert.match(
|
|
226
|
-
assert.
|
|
227
|
-
|
|
81
|
+
assert.equal(html.match(/aria-label="New folder"/g)?.length, 1);
|
|
82
|
+
assert.doesNotMatch(html, /aria-label="New folder inside/);
|
|
83
|
+
});
|
|
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
|
+
);
|
|
228
94
|
});
|
|
229
95
|
|
|
230
96
|
it("renders the empty state when no folder matches", () => {
|
|
@@ -127,8 +127,12 @@ function Picker({
|
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
|
|
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
|
+
*/
|
|
130
134
|
export const Default: Story = {
|
|
131
|
-
name: "Default (
|
|
135
|
+
name: "Default (collapsed to top level)",
|
|
132
136
|
render: () => <Picker />,
|
|
133
137
|
};
|
|
134
138
|
|
|
@@ -198,12 +202,44 @@ const typeFolderName = (canvasElement: HTMLElement, name: string) => {
|
|
|
198
202
|
|
|
199
203
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 60));
|
|
200
204
|
|
|
205
|
+
const expand = async (canvasElement: HTMLElement, ...labels: string[]) => {
|
|
206
|
+
for (const label of labels) {
|
|
207
|
+
clickAriaLabel(canvasElement, `Move to ${label}`);
|
|
208
|
+
await tick();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One tap picks the destination and opens it. The children arrive indented,
|
|
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.
|
|
216
|
+
*/
|
|
217
|
+
export const Expanded: Story = {
|
|
218
|
+
name: "A folder opened (children and its New folder)",
|
|
219
|
+
render: () => <Picker />,
|
|
220
|
+
play: async ({ canvasElement }) => {
|
|
221
|
+
await expand(canvasElement, "Travel");
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
201
225
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
226
|
+
* One open branch, three levels deep: the ancestors stay open because the
|
|
227
|
+
* folder you opened lives inside them.
|
|
228
|
+
*/
|
|
229
|
+
export const DeepExpansion: Story = {
|
|
230
|
+
name: "Opened three levels deep",
|
|
231
|
+
render: () => <Picker />,
|
|
232
|
+
play: async ({ canvasElement }) => {
|
|
233
|
+
await expand(canvasElement, "Travel", "Hotels", "Receipts");
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* "rec" matches two nested folders. Their parents open to put them on screen
|
|
239
|
+
* and read as the branches they are, not as an answer to what was typed.
|
|
204
240
|
*/
|
|
205
241
|
export const Filtered: Story = {
|
|
206
|
-
name: "Filtered (ancestors
|
|
242
|
+
name: "Filtered (ancestors opened for context)",
|
|
207
243
|
render: () => <Picker />,
|
|
208
244
|
play: async ({ canvasElement }) => {
|
|
209
245
|
typeFilter(canvasElement, "rec");
|
|
@@ -214,20 +250,22 @@ export const CreateTopLevel: Story = {
|
|
|
214
250
|
name: "Create a top-level folder",
|
|
215
251
|
render: () => <Picker />,
|
|
216
252
|
play: async ({ canvasElement }) => {
|
|
217
|
-
|
|
253
|
+
clickAriaLabel(canvasElement, "New folder");
|
|
218
254
|
await tick();
|
|
219
255
|
typeFolderName(canvasElement, "Insurance");
|
|
220
256
|
},
|
|
221
257
|
};
|
|
222
258
|
|
|
223
259
|
/**
|
|
224
|
-
* The
|
|
225
|
-
*
|
|
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.
|
|
226
263
|
*/
|
|
227
264
|
export const CreateSubfolder: Story = {
|
|
228
|
-
name: "Create a
|
|
265
|
+
name: "Create a folder inside an opened one",
|
|
229
266
|
render: () => <Picker />,
|
|
230
267
|
play: async ({ canvasElement }) => {
|
|
268
|
+
await expand(canvasElement, "Travel");
|
|
231
269
|
clickAriaLabel(canvasElement, "New folder inside Travel");
|
|
232
270
|
await tick();
|
|
233
271
|
typeFolderName(canvasElement, "Car hire");
|
|
@@ -242,6 +280,7 @@ export const CreatePending: Story = {
|
|
|
242
280
|
name: "Create — waiting for the server",
|
|
243
281
|
render: () => <Picker onCreateFolder={neverResolves} />,
|
|
244
282
|
play: async ({ canvasElement }) => {
|
|
283
|
+
await expand(canvasElement, "Finance");
|
|
245
284
|
clickAriaLabel(canvasElement, "New folder inside Finance");
|
|
246
285
|
await tick();
|
|
247
286
|
typeFolderName(canvasElement, "Mortgage");
|
|
@@ -261,6 +300,7 @@ export const CreateFailed: Story = {
|
|
|
261
300
|
/>
|
|
262
301
|
),
|
|
263
302
|
play: async ({ canvasElement }) => {
|
|
303
|
+
await expand(canvasElement, "Finance");
|
|
264
304
|
clickAriaLabel(canvasElement, "New folder inside Finance");
|
|
265
305
|
await tick();
|
|
266
306
|
typeFolderName(canvasElement, "Mortgage");
|
|
@@ -296,7 +336,7 @@ function WizardFolderStep() {
|
|
|
296
336
|
<p className="px-1 text-xs text-fg-muted">
|
|
297
337
|
{chosen
|
|
298
338
|
? `Moving to ${chosen.label}.`
|
|
299
|
-
: "
|
|
339
|
+
: "Tap a folder to open it, or make a new one where you want it."}
|
|
300
340
|
</p>
|
|
301
341
|
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-surface">
|
|
302
342
|
<FolderTreePicker
|
|
@@ -320,10 +360,16 @@ function WizardFolderStep() {
|
|
|
320
360
|
);
|
|
321
361
|
}
|
|
322
362
|
|
|
323
|
-
/**
|
|
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
|
+
*/
|
|
324
367
|
export const PhoneWizardStep: Story = {
|
|
325
368
|
name: "Phone — wizard folder step",
|
|
326
369
|
parameters: { layout: "fullscreen" },
|
|
327
370
|
globals: { viewport: { value: "mobile" } },
|
|
328
371
|
render: () => <WizardFolderStep />,
|
|
372
|
+
play: async ({ canvasElement }) => {
|
|
373
|
+
await expand(canvasElement, "Travel");
|
|
374
|
+
},
|
|
329
375
|
};
|