@remit/ui 0.0.67 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { renderToString } from "react-dom/server";
5
+ import { FolderRow, type FolderRowProps } from "./folder-row.js";
6
+
7
+ const render = (props: Partial<FolderRowProps>) =>
8
+ renderToString(
9
+ createElement(FolderRow, {
10
+ label: "Travel",
11
+ depth: 0,
12
+ expanded: false,
13
+ ariaLabel: "Move to Travel",
14
+ ...props,
15
+ }),
16
+ );
17
+
18
+ describe("FolderRow", () => {
19
+ it("is a tree item that carries its nesting and its label", () => {
20
+ const html = render({ depth: 2 });
21
+ assert.match(html, /<button[^>]*role="treeitem"/);
22
+ assert.match(html, /aria-level="3"/);
23
+ assert.match(html, /aria-label="Move to Travel"/);
24
+ assert.match(html, /aria-expanded="false"/);
25
+ assert.match(html, />Travel</);
26
+ });
27
+
28
+ it("indents by its depth and leaves a root flush", () => {
29
+ assert.match(render({ depth: 2 }), /width:28px/);
30
+ assert.doesNotMatch(render({ depth: 0 }), /style="width/);
31
+ });
32
+
33
+ it("turns the chevron once its children are on screen", () => {
34
+ assert.doesNotMatch(render({}), /rotate-90/);
35
+ assert.match(render({ expanded: true }), /rotate-90/);
36
+ });
37
+
38
+ it("checks the row that is the destination", () => {
39
+ const html = render({ selected: true });
40
+ assert.match(html, /aria-selected="true"/);
41
+ assert.match(html, /text-accent/);
42
+ });
43
+
44
+ it("leaves an unchosen row unselected and unchecked", () => {
45
+ const html = render({});
46
+ assert.match(html, /aria-selected="false"/);
47
+ assert.doesNotMatch(html, /text-accent/);
48
+ });
49
+
50
+ it("marks where the messages live now without disabling anything", () => {
51
+ const html = render({ current: true, currentTag: "current" });
52
+ assert.match(html, /aria-current="true"/);
53
+ assert.match(html, />current</);
54
+ assert.doesNotMatch(html, /disabled/);
55
+ });
56
+
57
+ it("renders a context branch as readable, not operable", () => {
58
+ const html = render({
59
+ context: true,
60
+ ariaLabel: "Travel (containing folder)",
61
+ });
62
+ assert.doesNotMatch(html, /<button/);
63
+ assert.match(html, /<div[^>]*role="treeitem"/);
64
+ assert.match(html, /aria-label="Travel \(containing folder\)"/);
65
+ assert.match(html, /opacity-60/);
66
+ });
67
+
68
+ it("insets the hairline to where the label starts", () => {
69
+ assert.match(render({ separated: true, depth: 1 }), /left:74px/);
70
+ assert.match(
71
+ render({ separated: true, depth: 1, context: true }),
72
+ /left:74px/,
73
+ );
74
+ assert.doesNotMatch(render({ depth: 1 }), /left:74px/);
75
+ });
76
+
77
+ it("takes its place in a roving tab order", () => {
78
+ assert.match(render({ tabIndex: 0 }), /tabindex="0"/);
79
+ assert.match(render({ tabIndex: -1 }), /tabindex="-1"/);
80
+ });
81
+ });
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { ReactNode } from "react";
3
+ import { FolderRow } from "./folder-row.js";
4
+
5
+ const meta: Meta<typeof FolderRow> = {
6
+ title: "Mail/FolderRow",
7
+ component: FolderRow,
8
+ parameters: { layout: "centered" },
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof FolderRow>;
13
+
14
+ function List({ children }: { children: ReactNode }) {
15
+ return (
16
+ <div className="w-[320px] overflow-hidden rounded-lg border border-line bg-surface font-sans text-fg">
17
+ {children}
18
+ </div>
19
+ );
20
+ }
21
+
22
+ export const Closed: Story = {
23
+ name: "Closed",
24
+ render: () => (
25
+ <List>
26
+ <FolderRow
27
+ label="Travel"
28
+ depth={0}
29
+ expanded={false}
30
+ ariaLabel="Move to Travel"
31
+ tabIndex={0}
32
+ />
33
+ </List>
34
+ ),
35
+ };
36
+
37
+ export const Open: Story = {
38
+ name: "Open, with its children indented under it",
39
+ render: () => (
40
+ <List>
41
+ <FolderRow
42
+ label="Travel"
43
+ depth={0}
44
+ expanded
45
+ ariaLabel="Move to Travel"
46
+ separated
47
+ />
48
+ <FolderRow
49
+ label="Hotels"
50
+ depth={1}
51
+ expanded={false}
52
+ ariaLabel="Move to Hotels"
53
+ separated
54
+ />
55
+ <FolderRow
56
+ label="Flights"
57
+ depth={1}
58
+ expanded={false}
59
+ ariaLabel="Move to Flights"
60
+ />
61
+ </List>
62
+ ),
63
+ };
64
+
65
+ export const Selected: Story = {
66
+ name: "The destination",
67
+ render: () => (
68
+ <List>
69
+ <FolderRow
70
+ label="Hotels"
71
+ depth={1}
72
+ expanded={false}
73
+ selected
74
+ ariaLabel="Move to Hotels"
75
+ />
76
+ </List>
77
+ ),
78
+ };
79
+
80
+ /** Where the messages live now: a marker, never a disabled control. */
81
+ export const Current: Story = {
82
+ name: "The folder you are in",
83
+ render: () => (
84
+ <List>
85
+ <FolderRow
86
+ label="Inbox"
87
+ depth={0}
88
+ expanded={false}
89
+ current
90
+ currentTag="current"
91
+ ariaLabel="Inbox (current folder)"
92
+ />
93
+ </List>
94
+ ),
95
+ };
96
+
97
+ /** A branch on screen only to hold the match under it: it reads, it never operates. */
98
+ export const Context: Story = {
99
+ name: "A branch held open by a match below it",
100
+ render: () => (
101
+ <List>
102
+ <FolderRow
103
+ label="Travel"
104
+ depth={0}
105
+ expanded
106
+ context
107
+ ariaLabel="Travel (containing folder)"
108
+ separated
109
+ />
110
+ <FolderRow
111
+ label="Hotels"
112
+ depth={1}
113
+ expanded={false}
114
+ ariaLabel="Move to Hotels"
115
+ />
116
+ </List>
117
+ ),
118
+ };
@@ -0,0 +1,147 @@
1
+ import { Check, ChevronRight, Folder } from "lucide-react";
2
+ import type { Ref } from "react";
3
+ import { cn } from "../lib/cn.js";
4
+
5
+ export const FOLDER_ROW_BASE =
6
+ "flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset";
7
+
8
+ export const FOLDER_INDENT_STEP = 14;
9
+
10
+ /**
11
+ * Where a row's text starts: `px-3` plus the chevron and folder icon columns
12
+ * with their gaps. The hairline separator is inset to it, so the line begins
13
+ * under the label the way a native mobile list draws it.
14
+ */
15
+ export const FOLDER_ROW_TEXT_INSET = 60;
16
+
17
+ export const FolderRowIndent = ({ depth }: { depth: number }) =>
18
+ depth > 0 ? (
19
+ <span
20
+ aria-hidden="true"
21
+ className="shrink-0"
22
+ style={{ width: depth * FOLDER_INDENT_STEP }}
23
+ />
24
+ ) : null;
25
+
26
+ export const FolderRowSeparator = ({ depth }: { depth: number }) => (
27
+ <span
28
+ aria-hidden="true"
29
+ className="pointer-events-none absolute right-0 bottom-0 h-px bg-line"
30
+ style={{ left: FOLDER_ROW_TEXT_INSET + depth * FOLDER_INDENT_STEP }}
31
+ />
32
+ );
33
+
34
+ export interface FolderRowProps {
35
+ label: string;
36
+ /** Indents the row and carries the nesting to `aria-level`. */
37
+ depth: number;
38
+ /** Its children are on screen. */
39
+ expanded: boolean;
40
+ /** What the row is announced as, e.g. `Move to Archive`. */
41
+ ariaLabel: string;
42
+ /** Chosen as the destination: checked, and announced as selected. */
43
+ selected?: boolean;
44
+ /**
45
+ * On screen only to hold a match below it. A branch reads and never
46
+ * operates, so it is neither focusable nor a target.
47
+ */
48
+ context?: boolean;
49
+ /** Where the messages live now: a marker, never a disabled control. */
50
+ current?: boolean;
51
+ /** Inline tag shown on the current row, e.g. `current`. */
52
+ currentTag?: string;
53
+ /** A hairline under the label, drawn for every row but the last. */
54
+ separated?: boolean;
55
+ tabIndex?: number;
56
+ onActivate?: () => void;
57
+ onFocus?: () => void;
58
+ ref?: Ref<HTMLButtonElement>;
59
+ }
60
+
61
+ /**
62
+ * One folder in a tree: opens where you tap it, and says whether it is the
63
+ * destination, the folder you are in, or a branch on the way to a match.
64
+ */
65
+ export const FolderRow = ({
66
+ label,
67
+ depth,
68
+ expanded,
69
+ ariaLabel,
70
+ selected = false,
71
+ context = false,
72
+ current = false,
73
+ currentTag,
74
+ separated = false,
75
+ tabIndex,
76
+ onActivate,
77
+ onFocus,
78
+ ref,
79
+ }: FolderRowProps) => {
80
+ const chevron = (
81
+ <ChevronRight
82
+ className={cn(
83
+ "size-4 shrink-0 text-fg-subtle transition-transform",
84
+ expanded && "rotate-90",
85
+ )}
86
+ aria-hidden="true"
87
+ />
88
+ );
89
+ const icon = (
90
+ <Folder className="size-4 shrink-0 text-fg-subtle" aria-hidden="true" />
91
+ );
92
+ if (context) {
93
+ return (
94
+ <div className="relative flex items-center">
95
+ {/* biome-ignore lint/a11y/useFocusableInteractive: an ancestor held on screen by a match below it — a branch, not a destination */}
96
+ <div
97
+ role="treeitem"
98
+ aria-level={depth + 1}
99
+ aria-selected={false}
100
+ aria-expanded={expanded}
101
+ aria-label={ariaLabel}
102
+ className={cn(FOLDER_ROW_BASE, "opacity-60")}
103
+ >
104
+ <FolderRowIndent depth={depth} />
105
+ {chevron}
106
+ {icon}
107
+ <span className="min-w-0 flex-1 truncate">{label}</span>
108
+ </div>
109
+ {separated && <FolderRowSeparator depth={depth} />}
110
+ </div>
111
+ );
112
+ }
113
+ return (
114
+ <div className="relative flex items-center">
115
+ <button
116
+ ref={ref}
117
+ type="button"
118
+ role="treeitem"
119
+ aria-level={depth + 1}
120
+ aria-selected={selected}
121
+ aria-expanded={expanded}
122
+ aria-current={current ? "true" : undefined}
123
+ aria-label={ariaLabel}
124
+ tabIndex={tabIndex}
125
+ onClick={onActivate}
126
+ onFocus={onFocus}
127
+ className={cn(
128
+ FOLDER_ROW_BASE,
129
+ "hover:bg-surface-raised active:bg-surface-sunken",
130
+ current && "text-fg-muted",
131
+ )}
132
+ >
133
+ <FolderRowIndent depth={depth} />
134
+ {chevron}
135
+ {icon}
136
+ <span className="min-w-0 flex-1 truncate">{label}</span>
137
+ {current && currentTag && (
138
+ <span className="shrink-0 text-xs text-fg-muted">{currentTag}</span>
139
+ )}
140
+ {selected && (
141
+ <Check className="size-4 shrink-0 text-accent" aria-hidden="true" />
142
+ )}
143
+ </button>
144
+ {separated && <FolderRowSeparator depth={depth} />}
145
+ </div>
146
+ );
147
+ };
@@ -24,10 +24,13 @@ const folders: FolderTreeNode[] = [
24
24
  { id: "inbox", label: "Inbox", path: "INBOX", isCurrent: true },
25
25
  { id: "travel", label: "Travel", path: "Travel" },
26
26
  { id: "hotels", label: "Hotels", path: "Travel/Hotels" },
27
+ { id: "receipts", label: "Receipts", path: "Travel/Hotels/Receipts" },
28
+ { id: "flights", label: "Flights", path: "Travel/Flights" },
27
29
  { id: "archive", label: "Archive", path: "Archive" },
28
30
  ];
29
31
 
30
32
  let dom: JSDOM;
33
+ const scrolledIntoView: Element[] = [];
31
34
  let container: HTMLElement;
32
35
  let root: Root;
33
36
  let act: typeof reactAct;
@@ -54,6 +57,12 @@ before(async () => {
54
57
  (
55
58
  globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
56
59
  ).IS_REACT_ACT_ENVIRONMENT = true;
60
+ Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
61
+ configurable: true,
62
+ value: function scrollIntoView(this: Element) {
63
+ scrolledIntoView.push(this);
64
+ },
65
+ });
57
66
 
58
67
  const react = await import("react");
59
68
  act = react.act;
@@ -68,6 +77,7 @@ after(() => {
68
77
  });
69
78
 
70
79
  beforeEach(() => {
80
+ scrolledIntoView.length = 0;
71
81
  container = dom.window.document.createElement("div");
72
82
  dom.window.document.body.append(container);
73
83
  root = createRoot(container);
@@ -154,9 +164,13 @@ const open = async (...labels: string[]) => {
154
164
  }
155
165
  };
156
166
 
157
- /** The block holding a folder's create action and any form opened from it. */
158
- const createBlockOf = (label: string): Element | null =>
159
- byAriaLabel(`New folder inside ${label}`)?.closest('[role="none"]') ?? null;
167
+ /**
168
+ * The block a folder's create action lives in — and, while the form is open,
169
+ * the form that took its place. Keyed on the anchor rather than the action, so
170
+ * it still resolves once the action has given way.
171
+ */
172
+ const createBlockOf = (anchor: string): Element | null =>
173
+ container.querySelector(`[data-create-anchor="${anchor}"]`);
160
174
 
161
175
  const rowOf = (label: string): Element | null =>
162
176
  byAriaLabel(`Move to ${label}`)?.closest('[role="none"]') ?? null;
@@ -229,6 +243,84 @@ describe("opening folders", () => {
229
243
  });
230
244
  });
231
245
 
246
+ describe("one open branch", () => {
247
+ it("closes the folder that was open when another opens", async () => {
248
+ await mount({});
249
+ await open("Travel");
250
+ assert.ok(byAriaLabel("Move to Hotels"));
251
+ await open("Archive");
252
+ assert.equal(byAriaLabel("Move to Hotels"), null);
253
+ assert.equal(
254
+ byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
255
+ "false",
256
+ );
257
+ assert.equal(
258
+ byAriaLabel("Move to Archive")?.getAttribute("aria-expanded"),
259
+ "true",
260
+ );
261
+ });
262
+
263
+ it("keeps the ancestors of the folder being opened open", async () => {
264
+ await mount({});
265
+ await open("Travel", "Hotels");
266
+ assert.ok(byAriaLabel("Move to Receipts"));
267
+ assert.equal(
268
+ byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
269
+ "true",
270
+ );
271
+ assert.equal(
272
+ byAriaLabel("Move to Hotels")?.getAttribute("aria-expanded"),
273
+ "true",
274
+ );
275
+ });
276
+
277
+ it("closes a sibling branch without closing the folder holding both", async () => {
278
+ await mount({});
279
+ await open("Travel", "Hotels");
280
+ await open("Flights");
281
+ assert.equal(byAriaLabel("Move to Receipts"), null);
282
+ assert.equal(
283
+ byAriaLabel("Move to Hotels")?.getAttribute("aria-expanded"),
284
+ "false",
285
+ );
286
+ assert.equal(
287
+ byAriaLabel("Move to Travel")?.getAttribute("aria-expanded"),
288
+ "true",
289
+ );
290
+ });
291
+
292
+ it("leaves one create action inside the branch that stayed open", async () => {
293
+ await mount({ onCreateFolder: resolving });
294
+ await open("Travel", "Hotels");
295
+ await open("Archive");
296
+ assert.equal(byAriaLabel("New folder inside Travel"), null);
297
+ assert.equal(byAriaLabel("New folder inside Hotels"), null);
298
+ assert.ok(byAriaLabel("New folder inside Archive"));
299
+ });
300
+ });
301
+
302
+ describe("create action prominence", () => {
303
+ it("keeps the pinned action prominent and the nested one quiet", async () => {
304
+ await mount({ onCreateFolder: resolving });
305
+ assert.equal(byAriaLabel("New folder")?.dataset.prominence, "prominent");
306
+ await open("Travel");
307
+ assert.equal(
308
+ byAriaLabel("New folder inside Travel")?.dataset.prominence,
309
+ "quiet",
310
+ );
311
+ assert.equal(byAriaLabel("New folder")?.dataset.prominence, "prominent");
312
+ });
313
+
314
+ it("stays the same action either way", async () => {
315
+ await mount({ onCreateFolder: resolving });
316
+ await open("Travel");
317
+ const pinned = byAriaLabel("New folder");
318
+ const nested = byAriaLabel("New folder inside Travel");
319
+ assert.equal(pinned?.tagName, nested?.tagName);
320
+ assert.equal(pinned?.textContent, nested?.textContent);
321
+ });
322
+ });
323
+
232
324
  describe("create form anchoring", () => {
233
325
  it("offers the action at the end of an opened folder's children", async () => {
234
326
  await mount({ onCreateFolder: resolving });
@@ -242,7 +334,7 @@ describe("create form anchoring", () => {
242
334
  await mount({ onCreateFolder: resolving });
243
335
  await open("Travel");
244
336
  await click(byAriaLabel("New folder inside Travel"));
245
- assert.ok(createBlockOf("Travel")?.querySelector("input"));
337
+ assert.ok(createBlockOf("travel")?.querySelector("input"));
246
338
  assert.equal(rowOf("Travel")?.querySelector("input"), null);
247
339
  });
248
340
 
@@ -250,7 +342,7 @@ describe("create form anchoring", () => {
250
342
  await mount({ onCreateFolder: resolving });
251
343
  await open("Travel");
252
344
  await click(byAriaLabel("New folder inside Travel"));
253
- assert.match(createBlockOf("Travel")?.textContent ?? "", /Inside\s*Travel/);
345
+ assert.match(createBlockOf("travel")?.textContent ?? "", /Inside\s*Travel/);
254
346
  assert.equal(container.querySelector("select"), null);
255
347
  });
256
348
 
@@ -259,8 +351,8 @@ describe("create form anchoring", () => {
259
351
  await open("Travel", "Hotels");
260
352
  await click(byAriaLabel("New folder inside Travel"));
261
353
  await click(byAriaLabel("New folder inside Hotels"));
262
- assert.equal(createBlockOf("Travel")?.querySelector("input"), null);
263
- assert.ok(createBlockOf("Hotels")?.querySelector("input"));
354
+ assert.equal(createBlockOf("travel")?.querySelector("input"), null);
355
+ assert.ok(createBlockOf("hotels")?.querySelector("input"));
264
356
  });
265
357
 
266
358
  it("creates at top level from the action pinned above the tree", async () => {
@@ -285,6 +377,61 @@ describe("create form anchoring", () => {
285
377
  });
286
378
  });
287
379
 
380
+ describe("the form takes the place of the action that opened it", () => {
381
+ it("replaces the pinned action", async () => {
382
+ await mount({ onCreateFolder: resolving });
383
+ await click(byAriaLabel("New folder"));
384
+ assert.equal(byAriaLabel("New folder"), null);
385
+ assert.ok(createBlockOf("top")?.querySelector("input"));
386
+ });
387
+
388
+ it("replaces a nested action and leaves the others alone", async () => {
389
+ await mount({ onCreateFolder: resolving });
390
+ await open("Travel", "Hotels");
391
+ await click(byAriaLabel("New folder inside Hotels"));
392
+ assert.equal(byAriaLabel("New folder inside Hotels"), null);
393
+ assert.ok(byAriaLabel("New folder inside Travel"));
394
+ assert.ok(byAriaLabel("New folder"));
395
+ });
396
+
397
+ it("puts the action back once the form closes", async () => {
398
+ await mount({ onCreateFolder: resolving });
399
+ await open("Travel");
400
+ await click(byAriaLabel("New folder inside Travel"));
401
+ await click(byText("Cancel"));
402
+ assert.ok(byAriaLabel("New folder inside Travel"));
403
+ });
404
+ });
405
+
406
+ describe("an opened form is reachable", () => {
407
+ it("brings the whole form into view, buttons included", async () => {
408
+ await mount({ onCreateFolder: resolving });
409
+ await open("Archive");
410
+ await click(byAriaLabel("New folder inside Archive"));
411
+ const scrolled = scrolledIntoView.at(-1);
412
+ assert.ok(scrolled, "nothing was scrolled into view");
413
+ assert.ok(scrolled.contains(nameField()));
414
+ assert.ok(scrolled.contains(byText("Create folder") ?? null));
415
+ assert.ok(scrolled.contains(byText("Cancel") ?? null));
416
+ });
417
+
418
+ it("scrolls the list rather than the page", async () => {
419
+ await mount({ onCreateFolder: resolving });
420
+ await open("Archive");
421
+ await click(byAriaLabel("New folder inside Archive"));
422
+ const tree = container.querySelector('[role="tree"]');
423
+ assert.ok(tree?.contains(scrolledIntoView.at(-1) ?? null));
424
+ });
425
+
426
+ it("keeps room below the last row for a form opened from it", async () => {
427
+ await mount({ onCreateFolder: resolving });
428
+ assert.match(
429
+ container.querySelector('[role="tree"]')?.className ?? "",
430
+ /\bpb-\d/,
431
+ );
432
+ });
433
+ });
434
+
288
435
  describe("create wait", () => {
289
436
  it("passes the opened folder's path as the parent and selects what comes back", async () => {
290
437
  const calls: Array<[string, string]> = [];
@@ -335,7 +482,7 @@ describe("create wait", () => {
335
482
  await click(byText("Create folder"));
336
483
  const alert = container.querySelector('[role="alert"]');
337
484
  assert.equal(alert?.textContent, "The mail server refused that name.");
338
- assert.ok(createBlockOf("Travel")?.querySelector("input"));
485
+ assert.ok(createBlockOf("travel")?.querySelector("input"));
339
486
  });
340
487
 
341
488
  it("says what is missing instead of going dead on an empty name", async () => {