@remit/ui 0.0.71 → 0.0.72

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.71",
3
+ "version": "0.0.72",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -158,6 +158,31 @@ const typeName = async (value: string) => {
158
158
  });
159
159
  };
160
160
 
161
+ const filterField = (): HTMLInputElement | null =>
162
+ container.querySelector('input[type="search"]');
163
+
164
+ const typeFilter = async (value: string) => {
165
+ const input = filterField();
166
+ assert.ok(input, "filter field not rendered");
167
+ const setter = Object.getOwnPropertyDescriptor(
168
+ dom.window.HTMLInputElement.prototype,
169
+ "value",
170
+ )?.set;
171
+ await act(async () => {
172
+ setter?.call(input, value);
173
+ input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
174
+ });
175
+ };
176
+
177
+ const press = async (target: Element | null | undefined, key: string) => {
178
+ assert.ok(target, "control not rendered");
179
+ await act(async () => {
180
+ target.dispatchEvent(
181
+ new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
182
+ );
183
+ });
184
+ };
185
+
161
186
  const open = async (...labels: string[]) => {
162
187
  for (const label of labels) {
163
188
  await click(byAriaLabel(`Move to ${label}`));
@@ -529,32 +554,131 @@ describe("create wait", () => {
529
554
  });
530
555
  });
531
556
 
532
- describe("filter and keyboard", () => {
533
- const filterField = (): HTMLInputElement | null =>
534
- container.querySelector('input[type="search"]');
535
-
536
- const typeFilter = async (value: string) => {
537
- const input = filterField();
538
- assert.ok(input);
539
- const setter = Object.getOwnPropertyDescriptor(
540
- dom.window.HTMLInputElement.prototype,
541
- "value",
542
- )?.set;
543
- await act(async () => {
544
- setter?.call(input, value);
545
- input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
557
+ describe("the form's keys stay in the form", () => {
558
+ it("takes a space in the name without acting on the tree", async () => {
559
+ const selected: string[] = [];
560
+ await mount({
561
+ onSelect: (id) => selected.push(id),
562
+ onCreateFolder: resolving,
546
563
  });
564
+ await open("Travel");
565
+ await click(byAriaLabel("New folder inside Travel"));
566
+ await press(nameField(), " ");
567
+ assert.deepEqual(selected, ["travel"]);
568
+ assert.ok(byAriaLabel("Move to Hotels"));
569
+ assert.ok(createBlockOf("travel")?.querySelector("input"));
570
+ });
571
+
572
+ it("closes the form on Escape and leaves the picker open", async () => {
573
+ let cancelled = 0;
574
+ await mount({
575
+ onCancel: () => {
576
+ cancelled += 1;
577
+ },
578
+ onCreateFolder: resolving,
579
+ });
580
+ await open("Travel");
581
+ await click(byAriaLabel("New folder inside Travel"));
582
+ await press(nameField(), "Escape");
583
+ assert.equal(nameField(), null);
584
+ assert.equal(cancelled, 0);
585
+ });
586
+ });
587
+
588
+ describe("a draft outlives the rows under it", () => {
589
+ const startDraft = async (props: PickerProps) => {
590
+ await mount(props);
591
+ await open("Travel");
592
+ await click(byAriaLabel("New folder inside Travel"));
593
+ await typeName("Car hire");
547
594
  };
548
595
 
549
- const press = async (target: Element | null | undefined, key: string) => {
550
- assert.ok(target, "control not rendered");
596
+ it("keeps the form and the typed name when another folder opens", async () => {
597
+ await startDraft({ onCreateFolder: resolving });
598
+ await open("Archive");
599
+ assert.equal(nameField()?.value, "Car hire");
600
+ assert.ok(createBlockOf("top")?.querySelector("input"));
601
+ assert.match(container.textContent ?? "", /Inside\s*Travel/);
602
+ });
603
+
604
+ it("keeps the form when its own folder is closed again", async () => {
605
+ await startDraft({ onCreateFolder: resolving });
606
+ await open("Travel");
607
+ assert.equal(byAriaLabel("Move to Hotels"), null);
608
+ assert.equal(nameField()?.value, "Car hire");
609
+ });
610
+
611
+ it("keeps the form when the filter empties the list", async () => {
612
+ await startDraft({ onCreateFolder: resolving });
613
+ await typeFilter("zzz");
614
+ assert.match(container.textContent ?? "", /No folders match "zzz"/);
615
+ assert.equal(nameField()?.value, "Car hire");
616
+ });
617
+
618
+ it("puts the form back among the children when its folder opens again", async () => {
619
+ await startDraft({ onCreateFolder: resolving });
620
+ await open("Archive");
621
+ await open("Travel");
622
+ assert.ok(createBlockOf("travel")?.querySelector("input"));
623
+ assert.equal(createBlockOf("top")?.querySelector("input"), null);
624
+ assert.equal(nameField()?.value, "Car hire");
625
+ });
626
+
627
+ it("states a failure that lands after the folder it was opened in closed", async () => {
628
+ let fail: ((error: Error) => void) | undefined;
629
+ await startDraft({
630
+ onCreateFolder: () =>
631
+ new Promise<FolderTreeNode>((_resolve, reject) => {
632
+ fail = reject;
633
+ }),
634
+ });
635
+ await click(byText("Create folder"));
636
+ await open("Archive");
637
+ assert.ok(byText("Creating folder…"), "the wait went off screen");
551
638
  await act(async () => {
552
- target.dispatchEvent(
553
- new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
554
- );
639
+ fail?.(new Error("The mail server refused that name."));
555
640
  });
556
- };
641
+ assert.equal(
642
+ container.querySelector('[role="alert"]')?.textContent,
643
+ "The mail server refused that name.",
644
+ );
645
+ assert.equal(nameField()?.value, "Car hire");
646
+ });
647
+
648
+ it("keeps the wait on screen when the filter clears the list", async () => {
649
+ await startDraft({
650
+ onCreateFolder: () => new Promise<FolderTreeNode>(() => undefined),
651
+ });
652
+ await click(byText("Create folder"));
653
+ await typeFilter("zzz");
654
+ assert.ok(byText("Creating folder…"), "the wait went off screen");
655
+ });
656
+
657
+ it("selects what comes back from a form the list moved under", async () => {
658
+ const selected: string[] = [];
659
+ let confirm: ((folder: FolderTreeNode) => void) | undefined;
660
+ await mount({
661
+ onSelect: (id) => selected.push(id),
662
+ onCreateFolder: () =>
663
+ new Promise<FolderTreeNode>((resolve) => {
664
+ confirm = resolve;
665
+ }),
666
+ });
667
+ await open("Travel");
668
+ await click(byAriaLabel("New folder inside Travel"));
669
+ await typeName("Car hire");
670
+ await click(byText("Create folder"));
671
+ await open("Archive");
672
+ assert.ok(byText("Creating folder…"), "the wait went off screen");
673
+ await act(async () => {
674
+ confirm?.(created("Car hire", "Travel"));
675
+ });
676
+ assert.deepEqual(selected, ["travel", "archive", "made"]);
677
+ assert.equal(nameField(), null);
678
+ });
679
+ });
557
680
 
681
+ describe("filter and keyboard", () => {
558
682
  const focused = (): string | null =>
559
683
  dom.window.document.activeElement?.getAttribute("aria-label") ?? null;
560
684
 
@@ -587,6 +711,12 @@ describe("filter and keyboard", () => {
587
711
  assert.match(container.textContent ?? "", /No folders match "zzz"/);
588
712
  });
589
713
 
714
+ it("says the list is empty rather than blaming the filter", async () => {
715
+ await mount({ folders: [] });
716
+ await typeFilter("zzz");
717
+ assert.match(container.textContent ?? "", /No folders to show/);
718
+ });
719
+
590
720
  it("picks and opens the focused row on Enter", async () => {
591
721
  const selected: string[] = [];
592
722
  await mount({ onSelect: (id) => selected.push(id) });
@@ -93,9 +93,10 @@ describe("FolderTreePicker render", () => {
93
93
  );
94
94
  });
95
95
 
96
- it("renders the empty state when no folder matches", () => {
96
+ it("says the list is empty when there is nothing to list", () => {
97
97
  const html = render({ folders: [] });
98
- assert.match(html, /No folders match/);
98
+ assert.match(html, /No folders to show/);
99
+ assert.doesNotMatch(html, /No folders match/);
99
100
  });
100
101
 
101
102
  it("applies caller-supplied labels", () => {
@@ -160,6 +160,7 @@ export const LongList: Story = {
160
160
  render: () => <Picker options={longFolders} />,
161
161
  };
162
162
 
163
+ /** An account with nothing to list: the message states that, not a filter. */
163
164
  export const Empty: Story = {
164
165
  name: "No folders",
165
166
  render: () => <Picker options={[]} />,
@@ -309,6 +310,46 @@ export const CreateFailed: Story = {
309
310
  },
310
311
  };
311
312
 
313
+ /**
314
+ * Looking somewhere else does not throw the draft away: opening another folder
315
+ * closes the branch the form was in, and the form is pinned above the tree with
316
+ * the typed name and the folder it will be made in.
317
+ */
318
+ export const CreateWhileTheListMoves: Story = {
319
+ name: "Create — kept while the list moves",
320
+ render: () => <Picker />,
321
+ play: async ({ canvasElement }) => {
322
+ await expand(canvasElement, "Travel");
323
+ clickAriaLabel(canvasElement, "New folder inside Travel");
324
+ await tick();
325
+ typeFolderName(canvasElement, "Car hire");
326
+ await tick();
327
+ await expand(canvasElement, "Finance");
328
+ },
329
+ };
330
+
331
+ /** A failure is stated where the user is looking, whatever the list has done. */
332
+ export const CreateFailedAfterTheListMoved: Story = {
333
+ name: "Create — failed after the list moved",
334
+ render: () => (
335
+ <Picker
336
+ onCreateFolder={rejects(
337
+ "The mail server refused the folder name. Try another one.",
338
+ )}
339
+ />
340
+ ),
341
+ play: async ({ canvasElement }) => {
342
+ await expand(canvasElement, "Travel");
343
+ clickAriaLabel(canvasElement, "New folder inside Travel");
344
+ await tick();
345
+ typeFolderName(canvasElement, "Car hire");
346
+ await tick();
347
+ clickText(canvasElement, "Create folder");
348
+ await tick();
349
+ await expand(canvasElement, "Finance");
350
+ },
351
+ };
352
+
312
353
  const wizardSteps: StepId[] = ["match", "folder", "rule", "review"];
313
354
 
314
355
  function WizardFolderStep() {
@@ -53,6 +53,8 @@ export interface FolderTreePickerLabels {
53
53
  /** Suffix announced for an ancestor held on screen by a match below it. */
54
54
  contextSuffix?: string;
55
55
  emptyMessage?: (query: string) => string;
56
+ /** Shown when there is no folder to list at all, filter or no filter. */
57
+ noFolders?: string;
56
58
  /** Accessible label for a selectable row, e.g. `Move to X`. */
57
59
  optionLabel?: (label: string) => string;
58
60
  newFolder?: string;
@@ -110,6 +112,7 @@ const defaultLabels: Required<FolderTreePickerLabels> = {
110
112
  currentTag: "current",
111
113
  contextSuffix: "(containing folder)",
112
114
  emptyMessage: (query) => `No folders match "${query}"`,
115
+ noFolders: "No folders to show",
113
116
  optionLabel: (label) => `Move to ${label}`,
114
117
  newFolder: "New folder",
115
118
  newSubfolder: (label) => `New folder inside ${label}`,
@@ -282,6 +285,11 @@ export const FolderTreePicker = ({
282
285
 
283
286
  const handleTreeKeyDown = useCallback(
284
287
  (event: ReactKeyboardEvent<HTMLElement>) => {
288
+ // The create form sits inside the tree, so what is typed into its name
289
+ // field reaches here too. Only a row's own keys move the tree.
290
+ const source = event.target;
291
+ if (!(source instanceof Element)) return;
292
+ if (!source.closest('[role="treeitem"]')) return;
285
293
  const move = (next: number) => {
286
294
  event.preventDefault();
287
295
  roving.current = true;
@@ -337,6 +345,14 @@ export const FolderTreePicker = ({
337
345
  [rows, focusedIndex, delimiter, activateRow, setExpanded, onCancel],
338
346
  );
339
347
 
348
+ const draftAnchorOnScreen =
349
+ draft !== null &&
350
+ draft.anchorId !== null &&
351
+ (displayRows?.some(
352
+ (entry) => entry.kind === "create" && entry.parent.id === draft.anchorId,
353
+ ) ??
354
+ false);
355
+
340
356
  const draftForm = draft && (
341
357
  <NewFolderForm
342
358
  parentLabel={draft.parentLabel}
@@ -414,8 +430,10 @@ export const FolderTreePicker = ({
414
430
  />
415
431
 
416
432
  {onCreateFolder && (
433
+ // Closing or filtering away the folder a draft was opened in leaves
434
+ // the form here instead of unmounting it mid-type or mid-create.
417
435
  <div className="shrink-0 border-b border-line" data-create-anchor="top">
418
- {draft?.anchorId === null ? (
436
+ {draft && !draftAnchorOnScreen ? (
419
437
  draftForm
420
438
  ) : (
421
439
  <NewFolderAction
@@ -430,7 +448,7 @@ export const FolderTreePicker = ({
430
448
 
431
449
  {rows.length === 0 ? (
432
450
  <p className="px-3 py-3 text-sm text-fg-muted" aria-live="polite">
433
- {text.emptyMessage(query)}
451
+ {folders.length === 0 ? text.noFolders : text.emptyMessage(query)}
434
452
  </p>
435
453
  ) : (
436
454
  // A flattened tree: `aria-level` carries the nesting the indentation