@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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/folder-manage-actions.render.test.ts +51 -0
  3. package/src/components/folder-manage-actions.stories.tsx +94 -0
  4. package/src/components/folder-manage-actions.tsx +54 -0
  5. package/src/components/folder-manager.render.test.ts +80 -0
  6. package/src/components/folder-manager.stories.tsx +184 -0
  7. package/src/components/folder-manager.tsx +77 -0
  8. package/src/components/folder-rename-dialog.render.test.ts +51 -0
  9. package/src/components/folder-rename-dialog.stories.tsx +66 -0
  10. package/src/components/folder-rename-dialog.tsx +95 -0
  11. package/src/components/folder-row.render.test.ts +88 -0
  12. package/src/components/folder-row.stories.tsx +118 -0
  13. package/src/components/folder-row.tsx +156 -0
  14. package/src/components/folder-tree-picker.create.test.ts +155 -8
  15. package/src/components/folder-tree-picker.render.test.ts +12 -284
  16. package/src/components/folder-tree-picker.stories.tsx +20 -7
  17. package/src/components/folder-tree-picker.tsx +130 -534
  18. package/src/components/new-folder-action.render.test.ts +44 -0
  19. package/src/components/new-folder-action.stories.tsx +92 -0
  20. package/src/components/new-folder-action.tsx +59 -0
  21. package/src/components/new-folder-form.render.test.ts +64 -0
  22. package/src/components/new-folder-form.stories.tsx +74 -0
  23. package/src/components/new-folder-form.tsx +126 -0
  24. package/src/index.ts +47 -0
  25. package/src/lib/folder-tree-focus.test.ts +98 -0
  26. package/src/lib/folder-tree-focus.ts +52 -0
  27. package/src/lib/folder-tree.test.ts +281 -0
  28. 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
- /** The list at rest: top level only, one tap deep into anything. */
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" sitting at the end of them as the last folder inside.
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
- /** Three levels open at once, each with its own place to add a folder. */
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 action sits among
254
- * its children and the form states the parent as text, so there is no second
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
- /** The pattern at the size it is used: the wizard's folder step on a phone. */
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
  };