@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.
@@ -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("renders the folders as a tree with a level per row", () => {
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, 6);
186
- assert.match(
187
- tagWith(html, 'aria-label="Move to Hotels"'),
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 Receipts"'),
192
- /aria-level="3"/,
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("offers a create row at the top and one per folder", () => {
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(html, /New folder<\/button>/);
226
- assert.match(html, /aria-label="New folder inside Travel"/);
227
- assert.match(html, /aria-label="New folder inside Inbox"/);
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 (browse the tree)",
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
- * "rec" matches two folders nested three levels down. Their parents stay on
203
- * screen so the branch still reads as a branch — dimmed, and not a destination.
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 kept for context)",
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
- clickText(canvasElement, "New folder");
253
+ clickAriaLabel(canvasElement, "New folder");
218
254
  await tick();
219
255
  typeFolderName(canvasElement, "Insurance");
220
256
  },
221
257
  };
222
258
 
223
259
  /**
224
- * The row you tapped is the answer to "inside where": the form opens under it
225
- * and states the parent as text, so there is no second 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.
226
263
  */
227
264
  export const CreateSubfolder: Story = {
228
- name: "Create a subfolder from a row",
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
- : "Browse to a folder, or make a new one where you want it."}
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
- /** 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
+ */
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
  };