@remit/ui 0.0.143 → 0.0.145

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.143",
3
+ "version": "0.0.145",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -54,6 +54,52 @@ describe("AttendeeRow", () => {
54
54
  );
55
55
  assert.doesNotMatch(html, /Organiser/);
56
56
  });
57
+
58
+ it("renders exactly what it always did for a caller with nothing to open", () => {
59
+ const html = renderToString(
60
+ createElement(AttendeeRow, { attendee: attendees[1] }),
61
+ );
62
+ assert.match(html, /^<div class="flex min-h-9 items-center gap-2\.5">/);
63
+ assert.match(html, /<span class="min-w-0 flex-1">/);
64
+ assert.doesNotMatch(
65
+ html,
66
+ /<button|aria-expanded|w-full|text-left|relative/,
67
+ );
68
+ });
69
+
70
+ it("becomes a control where a name leads somewhere", () => {
71
+ const html = renderToString(
72
+ createElement(AttendeeRow, {
73
+ attendee: attendees[0],
74
+ onActivate: () => undefined,
75
+ }),
76
+ );
77
+ assert.match(html, /<button/);
78
+ assert.match(html, /aria-expanded="false"/);
79
+ });
80
+
81
+ it("says the row is the one standing open", () => {
82
+ const html = renderToString(
83
+ createElement(AttendeeRow, {
84
+ attendee: attendees[0],
85
+ active: true,
86
+ onActivate: () => undefined,
87
+ }),
88
+ );
89
+ assert.match(html, /aria-expanded="true"/);
90
+ });
91
+
92
+ it("gives a thumb a row it can hit", () => {
93
+ const html = renderToString(
94
+ createElement(AttendeeRow, {
95
+ attendee: attendees[0],
96
+ onActivate: () => undefined,
97
+ touch: true,
98
+ }),
99
+ );
100
+ assert.match(html, /min-h-11/);
101
+ assert.doesNotMatch(html, /min-h-9/);
102
+ });
57
103
  });
58
104
 
59
105
  describe("AttendeeList", () => {
@@ -70,4 +116,65 @@ describe("AttendeeList", () => {
70
116
  "",
71
117
  );
72
118
  });
119
+
120
+ it("leaves the markup it always had when nothing can be opened", () => {
121
+ const html = renderToString(createElement(AttendeeList, { attendees }));
122
+ assert.doesNotMatch(html, /<button|aria-expanded|w-full/);
123
+ assert.doesNotMatch(html, /<div>/);
124
+ assert.equal(
125
+ html.match(/<div class="flex min-h-9 items-center gap-2\.5">/g)?.length,
126
+ attendees.length,
127
+ );
128
+ });
129
+
130
+ it("makes every row a control when a guest can be opened", () => {
131
+ const html = renderToString(
132
+ createElement(AttendeeList, { attendees, onActivate: () => undefined }),
133
+ );
134
+ assert.equal(html.match(/<button/g)?.length, attendees.length);
135
+ });
136
+
137
+ it("puts the context under the guest it is about and under no other", () => {
138
+ const html = renderToString(
139
+ createElement(AttendeeList, {
140
+ attendees,
141
+ activeEmail: "dana@northwind.example",
142
+ onActivate: () => undefined,
143
+ renderContext: (attendee) =>
144
+ createElement("p", null, `Recent mail · ${attendee.name}`),
145
+ }),
146
+ );
147
+ assert.match(html, /Recent mail · Dana Okafor/);
148
+ assert.doesNotMatch(html, /Recent mail · Priya Natarajan/);
149
+ });
150
+
151
+ it("names the disclosure the open row controls", () => {
152
+ const html = renderToString(
153
+ createElement(AttendeeList, {
154
+ attendees,
155
+ activeEmail: "dana@northwind.example",
156
+ onActivate: () => undefined,
157
+ renderContext: (attendee) =>
158
+ createElement("p", null, `Recent mail · ${attendee.name}`),
159
+ }),
160
+ );
161
+ const controls = html.match(/aria-controls="([^"]+)"/g) ?? [];
162
+ assert.equal(controls.length, 1);
163
+ const id = /aria-controls="([^"]+)"/.exec(html)?.[1];
164
+ assert.ok(id);
165
+ assert.ok(html.includes(`<div id="${id}"`));
166
+ });
167
+
168
+ it("opens no context for a guest who is not on the list", () => {
169
+ const html = renderToString(
170
+ createElement(AttendeeList, {
171
+ attendees,
172
+ activeEmail: "nobody@northwind.example",
173
+ onActivate: () => undefined,
174
+ renderContext: (attendee) =>
175
+ createElement("p", null, `Recent mail · ${attendee.name}`),
176
+ }),
177
+ );
178
+ assert.doesNotMatch(html, /Recent mail/);
179
+ });
73
180
  });
@@ -1,4 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
2
4
  import { AttendeeList, RsvpBadge } from "./attendee-row.js";
3
5
  import type { CalendarAttendee } from "./calendar-types.js";
4
6
 
@@ -56,6 +58,52 @@ export const List: Story = {
56
58
  ),
57
59
  };
58
60
 
61
+ /**
62
+ * The same list where the surface has something to say about the person behind
63
+ * a row. A row is then a disclosure: it opens under the guest, the same
64
+ * activation closes it, and what opens is the caller's — the kit places it and
65
+ * knows nothing else about it.
66
+ */
67
+ export const WithContext: Story = {
68
+ render: () => {
69
+ const [active, setActive] = useState("");
70
+ return (
71
+ <div className="max-w-sm rounded-lg border border-line bg-surface p-3">
72
+ <AttendeeList
73
+ attendees={attendees}
74
+ activeEmail={active}
75
+ onActivate={setActive}
76
+ renderContext={(attendee) => (
77
+ <p className="w-64 rounded-lg border border-line bg-surface-raised p-3 text-xs text-fg-muted">
78
+ {`Everything ${attendee.name} has written lately would go here.`}
79
+ </p>
80
+ )}
81
+ />
82
+ </div>
83
+ );
84
+ },
85
+ play: async ({ canvasElement }) => {
86
+ const canvas = within(canvasElement);
87
+ const guest = canvas.getByRole("button", { name: /Aisha Khan/ });
88
+
89
+ await userEvent.click(guest);
90
+
91
+ const opened = await waitFor(() =>
92
+ canvas.getByText(/Everything Aisha Khan has written/),
93
+ );
94
+ await expect(guest).toHaveAttribute("aria-expanded", "true");
95
+ await expect(guest).toHaveAttribute(
96
+ "aria-controls",
97
+ opened.parentElement?.id,
98
+ );
99
+
100
+ await userEvent.click(opened);
101
+
102
+ await waitFor(() => expect(opened).toBeInTheDocument());
103
+ await expect(guest).toHaveAttribute("aria-expanded", "true");
104
+ },
105
+ };
106
+
59
107
  export const EveryReply: Story = {
60
108
  render: () => (
61
109
  <div className="flex gap-4">
@@ -1,4 +1,5 @@
1
1
  import { Check, Clock, Minus, X } from "lucide-react";
2
+ import { Fragment, type ReactNode, useId } from "react";
2
3
  import { cn } from "../lib/cn.js";
3
4
  import { Avatar } from "./avatar.js";
4
5
  import type { CalendarAttendee, RsvpState } from "./calendar-types.js";
@@ -48,12 +49,31 @@ export function RsvpBadge({ rsvp, className }: RsvpBadgeProps) {
48
49
 
49
50
  export interface AttendeeRowProps {
50
51
  attendee: CalendarAttendee;
52
+ /** Marks the row whose context is open. */
53
+ active?: boolean;
54
+ /**
55
+ * Called with the guest to open and with "" to close, so a row that is
56
+ * already open closes on the next activation. Absent leaves the row inert,
57
+ * as today.
58
+ */
59
+ onActivate?: (email: string) => void;
60
+ /** The disclosure this row opens, so the button can name what it controls. */
61
+ contextId?: string;
62
+ /** A thumb needs a bigger row than a pointer does. */
63
+ touch?: boolean;
51
64
  className?: string;
52
65
  }
53
66
 
54
- export function AttendeeRow({ attendee, className }: AttendeeRowProps) {
55
- return (
56
- <div className={cn("flex min-h-9 items-center gap-2.5", className)}>
67
+ export function AttendeeRow({
68
+ attendee,
69
+ active = false,
70
+ onActivate,
71
+ contextId,
72
+ touch = false,
73
+ className,
74
+ }: AttendeeRowProps) {
75
+ const face = (
76
+ <>
57
77
  <Avatar name={attendee.name} email={attendee.email} size="sm" />
58
78
  <span className="min-w-0 flex-1">
59
79
  <span className="block truncate text-sm text-fg">{attendee.name}</span>
@@ -62,17 +82,74 @@ export function AttendeeRow({ attendee, className }: AttendeeRowProps) {
62
82
  )}
63
83
  </span>
64
84
  <RsvpBadge rsvp={attendee.rsvp} />
65
- </div>
85
+ </>
86
+ );
87
+
88
+ if (!onActivate)
89
+ return (
90
+ <div className={cn("flex min-h-9 items-center gap-2.5", className)}>
91
+ {face}
92
+ </div>
93
+ );
94
+
95
+ return (
96
+ <button
97
+ type="button"
98
+ aria-expanded={active}
99
+ aria-controls={active && contextId !== undefined ? contextId : undefined}
100
+ onClick={() => onActivate(active ? "" : attendee.email)}
101
+ onKeyDown={(event) => {
102
+ if (event.key === "Escape") onActivate("");
103
+ }}
104
+ onBlur={(event) => {
105
+ if (!active) return;
106
+ // Only focus landing on something else is focus leaving the guest.
107
+ // Clicking the text inside the disclosure focuses nothing at all, and
108
+ // what the row opened is rendered beside it, so neither closes it.
109
+ if (event.relatedTarget === null) return;
110
+ if (event.currentTarget.parentElement?.contains(event.relatedTarget))
111
+ return;
112
+ onActivate("");
113
+ }}
114
+ className={cn(
115
+ "flex w-full items-center gap-2.5 rounded-md px-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
116
+ touch ? "min-h-11" : "min-h-9",
117
+ active ? "bg-accent-2-soft" : "hover:bg-surface-sunken",
118
+ className,
119
+ )}
120
+ >
121
+ {face}
122
+ </button>
66
123
  );
67
124
  }
68
125
 
69
126
  export interface AttendeeListProps {
70
127
  attendees: CalendarAttendee[];
128
+ /** The guest whose context is open. Empty for none. */
129
+ activeEmail?: string;
130
+ /** Called with a guest to open and with "" to close. */
131
+ onActivate?: (email: string) => void;
132
+ /** Rendered under the open row. The kit knows nothing about mail. */
133
+ renderContext?: (attendee: CalendarAttendee) => ReactNode;
134
+ touch?: boolean;
71
135
  className?: string;
72
136
  }
73
137
 
74
- /** The full guest list with a one-line tally above it. */
75
- export function AttendeeList({ attendees, className }: AttendeeListProps) {
138
+ /**
139
+ * The full guest list with a one-line tally above it. A row is a control only
140
+ * where the surface has something to say about the person behind it, and what
141
+ * it opens is a disclosure under the row rather than a card over the pane: the
142
+ * same gesture closes it, so do Escape and taking focus elsewhere.
143
+ */
144
+ export function AttendeeList({
145
+ attendees,
146
+ activeEmail = "",
147
+ onActivate,
148
+ renderContext,
149
+ touch = false,
150
+ className,
151
+ }: AttendeeListProps) {
152
+ const listId = useId();
76
153
  if (attendees.length === 0) return null;
77
154
  const tally = (["accepted", "tentative", "declined", "noReply"] as const)
78
155
  .map((state) => ({
@@ -88,9 +165,30 @@ export function AttendeeList({ attendees, className }: AttendeeListProps) {
88
165
  <p className="pb-1 text-2xs uppercase tracking-wider text-fg-subtle">
89
166
  {`${attendees.length} guests · ${tally}`}
90
167
  </p>
91
- {attendees.map((attendee) => (
92
- <AttendeeRow key={attendee.email} attendee={attendee} />
93
- ))}
168
+ {attendees.map((attendee, index) => {
169
+ const active = attendee.email === activeEmail;
170
+ const contextId = `${listId}-guest-${index}`;
171
+ const row = (
172
+ <AttendeeRow
173
+ attendee={attendee}
174
+ active={active}
175
+ onActivate={onActivate}
176
+ contextId={renderContext ? contextId : undefined}
177
+ touch={touch}
178
+ />
179
+ );
180
+ if (!onActivate) return <Fragment key={attendee.email}>{row}</Fragment>;
181
+ return (
182
+ <div key={attendee.email}>
183
+ {row}
184
+ {active && renderContext && (
185
+ <div id={contextId} className="py-1">
186
+ {renderContext(attendee)}
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ })}
94
192
  </div>
95
193
  );
96
194
  }
@@ -101,4 +101,26 @@ describe("EventDetail", () => {
101
101
  it("names the calendar and its account", () => {
102
102
  assert.match(render({}), /Northwind/);
103
103
  });
104
+
105
+ it("leaves the guest list inert where a name leads nowhere", () => {
106
+ assert.doesNotMatch(render({}), /aria-expanded/);
107
+ });
108
+
109
+ it("opens the caller's context under the guest it is about", () => {
110
+ const html = renderToString(
111
+ createElement(EventDetail, {
112
+ event,
113
+ calendar,
114
+ whenText: "Wednesday 10 June · 10:00 – 11:30",
115
+ onEdit: () => undefined,
116
+ onDelete: () => undefined,
117
+ activeAttendee: "priya@northwind.example",
118
+ onActivateAttendee: () => undefined,
119
+ renderAttendeeContext: (attendee) =>
120
+ createElement("p", null, `Recent mail · ${attendee.name}`),
121
+ }),
122
+ );
123
+ assert.match(html, /aria-expanded="true"/);
124
+ assert.match(html, /Recent mail · Priya Natarajan/);
125
+ });
104
126
  });
@@ -13,6 +13,7 @@ import { cn } from "../lib/cn.js";
13
13
  import { AttendeeList, RsvpBadge } from "./attendee-row.js";
14
14
  import { Button } from "./button.js";
15
15
  import type {
16
+ CalendarAttendee,
16
17
  CalendarDescriptor,
17
18
  CalendarEventData,
18
19
  } from "./calendar-types.js";
@@ -28,6 +29,14 @@ export interface EventDetailProps {
28
29
  onOpenThread?: () => void;
29
30
  /** Puts the pane back to whatever it shows with nothing selected. */
30
31
  onClose?: () => void;
32
+ /** The guest whose context is open. Empty for none. */
33
+ activeAttendee?: string;
34
+ /** Called with a guest to open and with "" to close. Absent leaves the list inert. */
35
+ onActivateAttendee?: (email: string) => void;
36
+ /** Rendered under the open guest. The kit knows nothing about mail. */
37
+ renderAttendeeContext?: (attendee: CalendarAttendee) => React.ReactNode;
38
+ /** A thumb needs a bigger row than a pointer does. */
39
+ touch?: boolean;
31
40
  /**
32
41
  * `bare` drops the header and the scrolling frame, for a surface that already
33
42
  * carries both — a full-screen flow whose footer holds the same actions.
@@ -64,6 +73,10 @@ export function EventDetail({
64
73
  onDelete,
65
74
  onOpenThread,
66
75
  onClose,
76
+ activeAttendee = "",
77
+ onActivateAttendee,
78
+ renderAttendeeContext,
79
+ touch = false,
67
80
  chrome = "header",
68
81
  className,
69
82
  }: EventDetailProps) {
@@ -128,7 +141,14 @@ export function EventDetail({
128
141
  )}
129
142
 
130
143
  {event.attendees.length > 0 && (
131
- <AttendeeList attendees={event.attendees} className="mt-3" />
144
+ <AttendeeList
145
+ attendees={event.attendees}
146
+ activeEmail={activeAttendee}
147
+ onActivate={onActivateAttendee}
148
+ renderContext={renderAttendeeContext}
149
+ touch={touch}
150
+ className="mt-3"
151
+ />
132
152
  )}
133
153
 
134
154
  {event.notes !== "" && (
@@ -29,6 +29,13 @@ const folders: FolderTreeNode[] = [
29
29
  },
30
30
  ];
31
31
 
32
+ // A server that reports no hierarchy delimiter has a flat namespace, where a
33
+ // path carries no separator to read a trail out of.
34
+ const flatFolders: FolderTreeNode[] = [
35
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
36
+ { id: "mbx-work", label: "Work", path: "Work" },
37
+ ];
38
+
32
39
  const rule: FilterRule = {
33
40
  clauses: [{ id: "c1", field: "From", value: "a@example.com" }],
34
41
  matchOperator: "all",
@@ -61,6 +68,8 @@ interface MountOptions {
61
68
  signal?: AbortSignal,
62
69
  ) => Promise<FolderTreeNode>;
63
70
  onChangeMove?: (id: string) => void;
71
+ options?: readonly FolderTreeNode[];
72
+ delimiter?: string;
64
73
  }
65
74
 
66
75
  /** Holds the destination the way the app does, so a pick shows on screen. */
@@ -68,6 +77,8 @@ const mount = async ({
68
77
  initialDestination,
69
78
  onCreateFolder,
70
79
  onChangeMove,
80
+ options = folders,
81
+ delimiter,
71
82
  }: MountOptions = {}) => {
72
83
  const Controlled = () => {
73
84
  const [moveMailboxId, setMoveMailboxId] = useState<string | undefined>(
@@ -75,7 +86,8 @@ const mount = async ({
75
86
  );
76
87
  return createElement(FilterRuleEditor, {
77
88
  rule: { ...rule, moveMailboxId },
78
- folders,
89
+ folders: options,
90
+ delimiter,
79
91
  preview,
80
92
  onChangeMove: (id: string) => {
81
93
  setMoveMailboxId(id || undefined);
@@ -235,6 +247,17 @@ describe("FilterRuleEditor move destination", () => {
235
247
  assert.deepEqual(picked, [""]);
236
248
  });
237
249
 
250
+ it("reads a flat-namespace destination as its whole path, not per character", async () => {
251
+ await mount({ options: flatFolders, delimiter: "" });
252
+ await openTree();
253
+ await click(byAriaLabel("Move to Work"));
254
+ assert.ok(
255
+ byText("Move matches to Work"),
256
+ "the whole path is one segment when nothing nests",
257
+ );
258
+ assert.equal(byText("Move matches to W / o / r / k"), undefined);
259
+ });
260
+
238
261
  it("offers no create affordance without onCreateFolder", async () => {
239
262
  await mount();
240
263
  await openTree();
@@ -60,10 +60,15 @@ function LiveEditor({
60
60
  propertyRule,
61
61
  labels = demoLabels,
62
62
  initialClauseEdit,
63
+ folders = demoFolders,
64
+ delimiter,
63
65
  onCreateFolder,
64
66
  onCreateLabel,
65
67
  }: {
66
68
  initialRule: FilterRule;
69
+ folders?: FolderTreeNode[];
70
+ /** The provider's hierarchy separator; `""` is a flat namespace. */
71
+ delimiter?: string;
67
72
  semanticAvailable?: boolean;
68
73
  /** Offers the match-mode control; omit to render the editor without one. */
69
74
  initialMatchMode?: RuleMatchMode;
@@ -179,7 +184,8 @@ function LiveEditor({
179
184
  return (
180
185
  <FilterRuleEditor
181
186
  rule={rule}
182
- folders={demoFolders}
187
+ folders={folders}
188
+ delimiter={delimiter}
183
189
  labels={labels}
184
190
  preview={preview}
185
191
  semanticAvailable={semanticAvailable}
@@ -459,6 +465,30 @@ export const DestinationNestedFolders: Story = {
459
465
  },
460
466
  };
461
467
 
468
+ // A server that reports no hierarchy delimiter has a flat namespace: every
469
+ // folder sits at the top level and a path is a name, not a trail.
470
+ const flatFolders: FolderTreeNode[] = [
471
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX" },
472
+ { id: "mbx-archive", label: "Archive", path: "Archive" },
473
+ { id: "mbx-work", label: "Work", path: "Work" },
474
+ { id: "mbx-workshop", label: "Workshop", path: "Workshop" },
475
+ ];
476
+
477
+ /**
478
+ * A flat namespace, where the chosen destination reads as its whole path —
479
+ * `Work`, not one segment per character.
480
+ */
481
+ export const DestinationFlatNamespace: Story = {
482
+ name: "Destination — a flat namespace (server reports no delimiter)",
483
+ render: () => (
484
+ <LiveEditor
485
+ initialRule={{ ...demoRule, moveMailboxId: "mbx-work" }}
486
+ folders={flatFolders}
487
+ delimiter=""
488
+ />
489
+ ),
490
+ };
491
+
462
492
  /**
463
493
  * A new folder is made inside the folder the tree is looking at, so a filter can
464
494
  * point at `Travel/Car hire` without leaving the editor.
@@ -1,5 +1,5 @@
1
1
  import { Fragment, type ReactNode, useMemo, useState } from "react";
2
- import type { FolderTreeNode } from "../lib/folder-tree.js";
2
+ import { type FolderTreeNode, folderPathSegments } from "../lib/folder-tree.js";
3
3
  import { Button } from "./button.js";
4
4
  import {
5
5
  AddChipButton,
@@ -216,7 +216,7 @@ function MoveDestinationField({
216
216
 
217
217
  /** Two folders can share a leaf name, so a destination reads as its trail. */
218
218
  const trail = (folder: FolderTreeNode): string => {
219
- const segments = folder.path.split(delimiter);
219
+ const segments = folderPathSegments(folder.path, delimiter);
220
220
  return segments
221
221
  .map((segment, index) => {
222
222
  const path = segments.slice(0, index + 1).join(delimiter);
@@ -47,6 +47,17 @@ const folders: FolderTreeNode[] = [
47
47
  { id: "mbx-work-recruiting", label: "Recruiting", path: "Work/Recruiting" },
48
48
  ];
49
49
 
50
+ // A server that reports no hierarchy delimiter has a flat namespace: nothing
51
+ // nests, and `Work` is not a parent of `Workshop`.
52
+ const flatFolders: FolderTreeNode[] = [
53
+ { id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
54
+ { id: "mbx-archive", label: "Archive", path: "Archive" },
55
+ { id: "mbx-work", label: "Work", path: "Work" },
56
+ { id: "mbx-workshop", label: "Workshop", path: "Workshop" },
57
+ { id: "mbx-sent", label: "Sent", path: "Sent Items" },
58
+ { id: "mbx-trash", label: "Trash", path: "Deleted Messages" },
59
+ ];
60
+
50
61
  const longFolders: FolderTreeNode[] = [
51
62
  ...folders,
52
63
  ...Array.from({ length: 36 }, (_, i) => ({
@@ -100,6 +111,7 @@ const rejects = (message: string) => (): Promise<FolderTreeNode> =>
100
111
  function Picker({
101
112
  options = folders,
102
113
  onCreateFolder = createFolder,
114
+ delimiter,
103
115
  }: {
104
116
  options?: FolderTreeNode[];
105
117
  onCreateFolder?: (
@@ -107,6 +119,7 @@ function Picker({
107
119
  parentPath: string,
108
120
  signal?: AbortSignal,
109
121
  ) => Promise<FolderTreeNode>;
122
+ delimiter?: string;
110
123
  }) {
111
124
  const [selected, setSelected] = useState<string>();
112
125
  const [known, setKnown] = useState(options);
@@ -115,6 +128,7 @@ function Picker({
115
128
  <FolderTreePicker
116
129
  folders={known}
117
130
  selectedId={selected}
131
+ delimiter={delimiter}
118
132
  onSelect={setSelected}
119
133
  onCreateFolder={(name, parentPath, signal) =>
120
134
  onCreateFolder(name, parentPath, signal).then((created) => {
@@ -160,6 +174,15 @@ export const LongList: Story = {
160
174
  render: () => <Picker options={longFolders} />,
161
175
  };
162
176
 
177
+ /**
178
+ * A flat namespace: every folder sits at the top level and none of them opens,
179
+ * so a new folder can only be made at the top.
180
+ */
181
+ export const FlatNamespace: Story = {
182
+ name: "Flat namespace (server reports no delimiter)",
183
+ render: () => <Picker options={flatFolders} delimiter="" />,
184
+ };
185
+
163
186
  /** An account with nothing to list: the message states that, not a filter. */
164
187
  export const Empty: Story = {
165
188
  name: "No folders",
@@ -8,6 +8,7 @@ import {
8
8
  folderDepth,
9
9
  folderLeaf,
10
10
  folderParent,
11
+ folderPathSegments,
11
12
  matchesQuery,
12
13
  orderFolderNodes,
13
14
  queryExpandedPaths,
@@ -298,3 +299,50 @@ describe("folderLeaf", () => {
298
299
  assert.equal(folderLeaf("INBOX", "."), "INBOX");
299
300
  });
300
301
  });
302
+
303
+ describe("a flat namespace", () => {
304
+ const flat: FolderTreeNode[] = [
305
+ node("work", "Work", "Work"),
306
+ node("workshop", "Workshop", "Workshop"),
307
+ node("inbox", "Inbox", "INBOX"),
308
+ ];
309
+
310
+ it("keeps a path whole rather than splitting it into characters", () => {
311
+ assert.deepEqual(folderPathSegments("Projects/Q3", ""), ["Projects/Q3"]);
312
+ assert.deepEqual(folderPathSegments("Projects/Q3", "/"), [
313
+ "Projects",
314
+ "Q3",
315
+ ]);
316
+ });
317
+
318
+ it("makes every folder a root with no parent, depth or ancestors", () => {
319
+ assert.equal(folderParent("Projects/Q3", ""), "");
320
+ assert.equal(folderDepth("Projects/Q3", ""), 0);
321
+ assert.deepEqual(folderAncestors("Projects/Q3", ""), []);
322
+ });
323
+
324
+ it("puts the whole list on screen at the top level", () => {
325
+ const rows = collapseFolderTree(orderFolderNodes(flat, ""), new Set(), "");
326
+ assert.deepEqual(paths(rows), ["Work", "Workshop", "INBOX"]);
327
+ assert.deepEqual(
328
+ rows.map((row) => row.depth),
329
+ [0, 0, 0],
330
+ );
331
+ });
332
+
333
+ it("offers no create action inside a folder, not even a prefix match", () => {
334
+ const rows = collapseFolderTree(
335
+ orderFolderNodes(flat, ""),
336
+ new Set(["Work"]),
337
+ "",
338
+ );
339
+ assert.deepEqual(
340
+ withCreateRows(rows, "").map((entry) =>
341
+ entry.kind === "create"
342
+ ? `new inside ${entry.parent.path}`
343
+ : entry.row.folder.path,
344
+ ),
345
+ ["Work", "Workshop", "INBOX"],
346
+ );
347
+ });
348
+ });
@@ -38,28 +38,39 @@ export type FolderTreeDisplayRow =
38
38
  | { kind: "folder"; row: FolderTreeRow; index: number }
39
39
  | { kind: "create"; parent: FolderTreeNode; depth: number };
40
40
 
41
- // A server that reports no delimiter has a flat namespace, so the path is its
42
- // own leaf; splitting on "" would return single characters.
41
+ // A server that reports no delimiter has a flat namespace: the path is its own
42
+ // leaf and every folder is a root. Splitting on "" would return single
43
+ // characters, and `"Inbox".lastIndexOf("")` is 5 rather than -1, so each of
44
+ // these answers the flat case before it touches the path.
45
+ export const folderPathSegments = (
46
+ path: string,
47
+ delimiter: string,
48
+ ): string[] => (delimiter.length === 0 ? [path] : path.split(delimiter));
49
+
43
50
  export const folderLeaf = (path: string, delimiter: string): string => {
44
- if (delimiter.length === 0) return path;
45
- const parts = path.split(delimiter);
51
+ const parts = folderPathSegments(path, delimiter);
46
52
  return parts[parts.length - 1] || path;
47
53
  };
48
54
 
49
55
  export const folderParent = (path: string, delimiter: string): string => {
56
+ if (delimiter.length === 0) return "";
50
57
  const cut = path.lastIndexOf(delimiter);
51
58
  return cut === -1 ? "" : path.slice(0, cut);
52
59
  };
53
60
 
54
61
  export const folderDepth = (path: string, delimiter: string): number =>
55
- path.split(delimiter).length - 1;
62
+ folderPathSegments(path, delimiter).length - 1;
56
63
 
64
+ // Every step up is strictly shorter than the path below it, so the walk is
65
+ // bounded by the length of the path whatever a parent comes back as.
57
66
  export const folderAncestors = (path: string, delimiter: string): string[] => {
58
67
  const out: string[] = [];
59
- let parent = folderParent(path, delimiter);
60
- while (parent) {
68
+ let child = path;
69
+ let parent = folderParent(child, delimiter);
70
+ while (parent && parent.length < child.length) {
61
71
  out.push(parent);
62
- parent = folderParent(parent, delimiter);
72
+ child = parent;
73
+ parent = folderParent(child, delimiter);
63
74
  }
64
75
  return out;
65
76
  };
@@ -182,12 +193,16 @@ export const collapseFolderTree = (
182
193
 
183
194
  /**
184
195
  * Drops a create action at the end of every open folder's children, so "New
185
- * folder" reads as the last folder inside the one you opened.
196
+ * folder" reads as the last folder inside the one you opened. A flat namespace
197
+ * has no inside, so it gets the rows on their own and creates at the top level.
186
198
  */
187
199
  export const withCreateRows = (
188
200
  rows: readonly FolderTreeRow[],
189
201
  delimiter: string,
190
202
  ): FolderTreeDisplayRow[] => {
203
+ if (delimiter.length === 0)
204
+ return rows.map((row, index) => ({ kind: "folder", row, index }));
205
+
191
206
  const out: FolderTreeDisplayRow[] = [];
192
207
  const open: FolderTreeRow[] = [];
193
208