@remit/ui 0.0.142 → 0.0.144

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.142",
3
+ "version": "0.0.144",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,9 +1,11 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { useState } from "react";
2
+ import { useRef, useState } from "react";
3
3
  import { expect, userEvent, waitFor, within } from "storybook/test";
4
4
  import {
5
5
  type AddressEntry,
6
6
  ComposeAddressField,
7
+ type ComposeAddressFieldHandle,
8
+ type ParsedAddressInput,
7
9
  } from "./compose-address-field.js";
8
10
 
9
11
  const KNOWN: AddressEntry[] = [
@@ -114,6 +116,198 @@ export const IncompleteAddressIsNotTaken: Story = {
114
116
  },
115
117
  };
116
118
 
119
+ const NO_RECIPIENT_REFUSAL = "Add a To address before sending.";
120
+
121
+ const notAnAddress = (text: string) =>
122
+ `To holds "${text}", which is not an address.`;
123
+
124
+ /**
125
+ * A press elsewhere is what an address typed and left in the field has to
126
+ * survive, and this is the shape the composer holds the field in for it.
127
+ *
128
+ * The press reads the field through `commitPending`, in the same tick, rather
129
+ * than the list the field has got round to committing — the blur timer is still
130
+ * 150 ms away from that. What the button says before it is pressed comes from
131
+ * `onPendingChange`, so neither refusal ever stands while an address is on
132
+ * screen, and text that is not an address stops the send instead of leaving
133
+ * with it.
134
+ */
135
+ const SendHarness = ({ initial = [] }: { initial?: AddressEntry[] }) => {
136
+ const [addresses, setAddresses] = useState<AddressEntry[]>(initial);
137
+ const [pending, setPending] = useState<ParsedAddressInput>({
138
+ entries: [],
139
+ unparsed: "",
140
+ });
141
+ const [sentTo, setSentTo] = useState<string[] | undefined>(undefined);
142
+ const [refusal, setRefusal] = useState<string | undefined>(undefined);
143
+ const field = useRef<ComposeAddressFieldHandle>(null);
144
+
145
+ const refuse = (committed: ParsedAddressInput["unparsed"], count: number) => {
146
+ if (committed.trim()) return notAnAddress(committed.trim());
147
+ if (count === 0) return NO_RECIPIENT_REFUSAL;
148
+ return undefined;
149
+ };
150
+
151
+ return (
152
+ <div className="w-[520px]">
153
+ <ComposeAddressField
154
+ label="To"
155
+ addresses={addresses}
156
+ onChange={setAddresses}
157
+ placeholder="Recipients"
158
+ onPendingChange={setPending}
159
+ ref={field}
160
+ />
161
+ <button
162
+ type="button"
163
+ onClick={() => {
164
+ const beforePress = refuse(
165
+ pending.unparsed,
166
+ addresses.length + pending.entries.length,
167
+ );
168
+ if (beforePress !== undefined) {
169
+ setRefusal(beforePress);
170
+ return;
171
+ }
172
+ const committed = field.current?.commitPending();
173
+ const recipients = committed?.addresses ?? addresses;
174
+ const onPress = refuse(committed?.unparsed ?? "", recipients.length);
175
+ if (onPress !== undefined) {
176
+ setRefusal(onPress);
177
+ return;
178
+ }
179
+ setSentTo(recipients.map((recipient) => recipient.email));
180
+ }}
181
+ >
182
+ Send
183
+ </button>
184
+ {sentTo !== undefined && <p data-testid="sent-to">{sentTo.join(", ")}</p>}
185
+ {refusal !== undefined && <p data-testid="refusal">{refusal}</p>}
186
+ </div>
187
+ );
188
+ };
189
+
190
+ export const SendTakesTheAddressStillInTheField: Story = {
191
+ render: () => <SendHarness />,
192
+ play: async ({ canvasElement }) => {
193
+ const canvas = within(canvasElement);
194
+ await userEvent.type(
195
+ canvas.getByLabelText("To:"),
196
+ "typed@northwind.example",
197
+ );
198
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
199
+ await expect(canvas.getByTestId("sent-to")).toHaveTextContent(
200
+ "typed@northwind.example",
201
+ );
202
+ await expect(canvas.queryByTestId("refusal")).not.toBeInTheDocument();
203
+ },
204
+ };
205
+
206
+ /** With nothing typed and no chip there is nothing to send to, and it says so. */
207
+ export const SendRefusesAnEmptyField: Story = {
208
+ render: () => <SendHarness />,
209
+ play: async ({ canvasElement }) => {
210
+ const canvas = within(canvasElement);
211
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
212
+ await expect(canvas.getByTestId("refusal")).toHaveTextContent(
213
+ NO_RECIPIENT_REFUSAL,
214
+ );
215
+ },
216
+ };
217
+
218
+ export const SendTakesTheAddressAfterAChip: Story = {
219
+ render: () => (
220
+ <SendHarness initial={[{ email: "chipped@northwind.example" }]} />
221
+ ),
222
+ play: async ({ canvasElement }) => {
223
+ const canvas = within(canvasElement);
224
+ await userEvent.type(
225
+ canvas.getByLabelText("To:"),
226
+ "typed@northwind.example",
227
+ );
228
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
229
+ await expect(canvas.getByTestId("sent-to")).toHaveTextContent(
230
+ "chipped@northwind.example, typed@northwind.example",
231
+ );
232
+ },
233
+ };
234
+
235
+ /** A pasted list arrives in one onChange and never sees the comma keydown. */
236
+ export const SendTakesAPastedList: Story = {
237
+ render: () => <SendHarness />,
238
+ play: async ({ canvasElement }) => {
239
+ const canvas = within(canvasElement);
240
+ await userEvent.click(canvas.getByLabelText("To:"));
241
+ await userEvent.paste("alice@northwind.example, bob@northwind.example");
242
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
243
+ await expect(canvas.getByTestId("sent-to")).toHaveTextContent(
244
+ "alice@northwind.example, bob@northwind.example",
245
+ );
246
+ },
247
+ };
248
+
249
+ /**
250
+ * Text that is not an address stops the send and is quoted back. Going ahead
251
+ * would deliver the message to everyone but the person that text was for, and
252
+ * the composer closing on it would take the text away unread.
253
+ */
254
+ export const SendRefusesTextThatIsNotAnAddress: Story = {
255
+ render: () => (
256
+ <SendHarness initial={[{ email: "chipped@northwind.example" }]} />
257
+ ),
258
+ play: async ({ canvasElement }) => {
259
+ const canvas = within(canvasElement);
260
+ const input = canvas.getByLabelText<HTMLInputElement>("To:");
261
+ await userEvent.type(input, "alice@northwind");
262
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
263
+
264
+ await expect(canvas.getByTestId("refusal")).toHaveTextContent(
265
+ notAnAddress("alice@northwind"),
266
+ );
267
+ await expect(canvas.queryByTestId("sent-to")).not.toBeInTheDocument();
268
+ await expect(input).toHaveValue("alice@northwind");
269
+ },
270
+ };
271
+
272
+ /**
273
+ * Candidates a complete address is a substring of, so the typed text is itself
274
+ * committable and the suggestion picked is somebody else. That is what makes
275
+ * the story below a claim about the blur timer rather than about deduplication.
276
+ */
277
+ const NEARBY: AddressEntry[] = [
278
+ { email: "beta@northwind.example", displayName: "Beta Team" },
279
+ { email: "a@northwind.example.org", displayName: "Alpha Team" },
280
+ ];
281
+
282
+ /**
283
+ * The other press the field has to survive, and the reason the blur commit is
284
+ * still on a timer: a click travelling towards a suggestion must not be answered
285
+ * by the typed text becoming a chip and the list going with it.
286
+ */
287
+ export const SuggestionSurvivesTheBlurItCauses: Story = {
288
+ render: () => <Harness candidates={NEARBY} />,
289
+ play: async ({ canvasElement }) => {
290
+ const canvas = within(canvasElement);
291
+ const input = canvas.getByLabelText<HTMLInputElement>("To:");
292
+ await userEvent.type(input, "a@northwind.example");
293
+ const list = await canvas.findByRole("listbox");
294
+ await userEvent.click(within(list).getByText("Beta Team"));
295
+
296
+ await expect(canvas.getByText("Beta Team")).toBeVisible();
297
+ await expect(input).toHaveValue("");
298
+
299
+ // Past the blur timer, not merely past the click: the commit it scheduled
300
+ // is cancelled, so the address that was typed never becomes a second chip.
301
+ await new Promise((resolve) => setTimeout(resolve, 400));
302
+ await expect(
303
+ canvas.getAllByRole("button", { name: /^Remove / }),
304
+ ).toHaveLength(1);
305
+ await expect(
306
+ canvas.queryByText("a@northwind.example"),
307
+ ).not.toBeInTheDocument();
308
+ },
309
+ };
310
+
117
311
  export const BackspaceRemovesTheLastChip: Story = {
118
312
  render: () => (
119
313
  <Harness
@@ -1,11 +1,50 @@
1
- import { useCallback, useMemo, useRef, useState } from "react";
1
+ import type { Ref } from "react";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useImperativeHandle,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import {
11
+ type AddressEntry,
12
+ type ParsedAddressInput,
13
+ parseAddressInput,
14
+ } from "../lib/parse-address-input.js";
2
15
  import { useSuggestList } from "../lib/use-suggest-list.js";
3
16
  import { AddressTag } from "./address-tag.js";
4
17
  import { type Suggestion, SuggestList } from "./suggest-list.js";
5
18
 
6
- export interface AddressEntry {
7
- email: string;
8
- displayName?: string;
19
+ export type { AddressEntry, ParsedAddressInput };
20
+
21
+ /** What the field holds, after a commit. */
22
+ export interface ComposeAddressCommit {
23
+ /** Every address the field now has, the ones just taken included. */
24
+ addresses: AddressEntry[];
25
+ /** What was left in the field because it is not an address. */
26
+ unparsed: string;
27
+ }
28
+
29
+ /**
30
+ * What a caller acting on the field's contents holds it by.
31
+ *
32
+ * The field commits on blur behind a timer, so that a click travelling towards a
33
+ * suggestion is not answered by the list disappearing under it. Anything that
34
+ * acts on the recipients — sending — is a press that blurs the field, and so
35
+ * lands inside that window and reads the list as it stood before the last
36
+ * address was typed. `commitPending` closes it: it takes what is in the field
37
+ * and hands back the list including it, in the same tick as the press.
38
+ */
39
+ export interface ComposeAddressFieldHandle {
40
+ /**
41
+ * Take the addresses the field is holding and report what it holds after.
42
+ * Anything that is not an address stays in the field and is named in
43
+ * `unparsed`, for the caller to refuse on rather than send without.
44
+ */
45
+ commitPending: () => ComposeAddressCommit;
46
+ /** Drop what is typed and not committed — the field's share of a new document. */
47
+ clearPending: () => void;
9
48
  }
10
49
 
11
50
  export interface ComposeAddressFieldProps {
@@ -17,28 +56,19 @@ export interface ComposeAddressFieldProps {
17
56
  suggestions?: readonly AddressEntry[];
18
57
  /** The current text, reported so the caller can look candidates up. */
19
58
  onQueryChange?: (query: string) => void;
59
+ /**
60
+ * What the field is holding but has not committed, so a caller whose own
61
+ * state turns on having a recipient counts what is on screen.
62
+ */
63
+ onPendingChange?: (pending: ParsedAddressInput) => void;
64
+ ref?: Ref<ComposeAddressFieldHandle>;
20
65
  }
21
66
 
22
67
  /** Beyond Enter, the keys that take the highlighted suggestion in a chips field. */
23
68
  const ACCEPT_KEYS = ["Tab", ","] as const;
24
69
 
25
- const isValidEmail = (value: string): boolean =>
26
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
27
-
28
- const parseEmailInput = (value: string): AddressEntry | undefined => {
29
- const trimmed = value.trim();
30
- if (!trimmed) return undefined;
31
-
32
- const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
33
- if (angleMatch) {
34
- const displayName = angleMatch[1].trim();
35
- const email = angleMatch[2].trim();
36
- if (isValidEmail(email)) return { email, displayName };
37
- }
38
-
39
- if (isValidEmail(trimmed)) return { email: trimmed };
40
- return undefined;
41
- };
70
+ /** How long a click has to reach a suggestion before the blur commits the field. */
71
+ const BLUR_COMMIT_MS = 150;
42
72
 
43
73
  export const ComposeAddressField = ({
44
74
  label,
@@ -47,24 +77,49 @@ export const ComposeAddressField = ({
47
77
  placeholder,
48
78
  suggestions = [],
49
79
  onQueryChange,
80
+ onPendingChange,
81
+ ref,
50
82
  }: ComposeAddressFieldProps) => {
51
83
  const [inputValue, setInputValue] = useState("");
52
84
  const inputRef = useRef<HTMLInputElement>(null);
53
85
 
86
+ const pending = useMemo(() => parseAddressInput(inputValue), [inputValue]);
87
+ useEffect(() => {
88
+ onPendingChange?.(pending);
89
+ }, [pending, onPendingChange]);
90
+
91
+ const blurCommitRef = useRef<ReturnType<typeof setTimeout> | undefined>(
92
+ undefined,
93
+ );
94
+ const cancelBlurCommit = useCallback(() => {
95
+ if (blurCommitRef.current === undefined) return;
96
+ clearTimeout(blurCommitRef.current);
97
+ blurCommitRef.current = undefined;
98
+ }, []);
99
+ useEffect(() => cancelBlurCommit, [cancelBlurCommit]);
100
+
54
101
  const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
55
102
  const filteredSuggestions =
56
103
  inputValue.length >= 2
57
104
  ? suggestions.filter((s) => !existingEmails.has(s.email.toLowerCase()))
58
105
  : [];
59
106
 
107
+ const setInput = useCallback(
108
+ (next: string) => {
109
+ setInputValue(next);
110
+ onQueryChange?.(next);
111
+ },
112
+ [onQueryChange],
113
+ );
114
+
60
115
  const addAddress = useCallback(
61
116
  (entry: AddressEntry) => {
117
+ cancelBlurCommit();
62
118
  if (existingEmails.has(entry.email.toLowerCase())) return;
63
119
  onChange([...addresses, entry]);
64
- setInputValue("");
65
- onQueryChange?.("");
120
+ setInput("");
66
121
  },
67
- [addresses, existingEmails, onChange, onQueryChange],
122
+ [addresses, existingEmails, onChange, setInput, cancelBlurCommit],
68
123
  );
69
124
 
70
125
  const removeAddress = useCallback(
@@ -82,12 +137,49 @@ export const ComposeAddressField = ({
82
137
  [addAddress],
83
138
  );
84
139
 
85
- const commitInput = useCallback(() => {
86
- const entry = parseEmailInput(inputValue);
87
- if (entry) {
88
- addAddress(entry);
140
+ const commitPending = useCallback((): ComposeAddressCommit => {
141
+ cancelBlurCommit();
142
+ if (pending.entries.length === 0) {
143
+ return { addresses, unparsed: pending.unparsed };
144
+ }
145
+
146
+ const taken = new Set(existingEmails);
147
+ const next = [...addresses];
148
+ for (const entry of pending.entries) {
149
+ const key = entry.email.toLowerCase();
150
+ if (taken.has(key)) continue;
151
+ taken.add(key);
152
+ next.push(entry);
89
153
  }
90
- }, [inputValue, addAddress]);
154
+
155
+ onChange(next);
156
+ setInput(pending.unparsed);
157
+ return { addresses: next, unparsed: pending.unparsed };
158
+ }, [
159
+ pending,
160
+ addresses,
161
+ existingEmails,
162
+ onChange,
163
+ setInput,
164
+ cancelBlurCommit,
165
+ ]);
166
+
167
+ const clearPending = useCallback(() => {
168
+ cancelBlurCommit();
169
+ setInput("");
170
+ }, [setInput, cancelBlurCommit]);
171
+
172
+ useImperativeHandle(ref, () => ({ commitPending, clearPending }), [
173
+ commitPending,
174
+ clearPending,
175
+ ]);
176
+
177
+ // A blur that has already been answered — by a send committing the field in
178
+ // the same press — must not commit again off the value it saw on the way out.
179
+ const latestCommitRef = useRef(commitPending);
180
+ useEffect(() => {
181
+ latestCommitRef.current = commitPending;
182
+ }, [commitPending]);
91
183
 
92
184
  // The open state, the highlight, and the arrow/Enter/Escape handling are the
93
185
  // app's one typeahead behaviour, shared with the filter-rule value field.
@@ -119,7 +211,7 @@ export const ComposeAddressField = ({
119
211
  if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
120
212
  if (inputValue.trim()) {
121
213
  e.preventDefault();
122
- commitInput();
214
+ commitPending();
123
215
  }
124
216
  }
125
217
  },
@@ -128,16 +220,21 @@ export const ComposeAddressField = ({
128
220
  addresses.length,
129
221
  removeAddress,
130
222
  suggest.handleKeyDown,
131
- commitInput,
223
+ commitPending,
132
224
  ],
133
225
  );
134
226
 
227
+ // The delay is what keeps a click on a suggestion alive: the press blurs the
228
+ // input before it lands, and committing straight away would take the list out
229
+ // from under the pointer.
135
230
  const handleBlur = useCallback(() => {
136
- setTimeout(() => {
137
- commitInput();
231
+ cancelBlurCommit();
232
+ blurCommitRef.current = setTimeout(() => {
233
+ blurCommitRef.current = undefined;
234
+ latestCommitRef.current();
138
235
  suggest.dismiss();
139
- }, 150);
140
- }, [commitInput, suggest.dismiss]);
236
+ }, BLUR_COMMIT_MS);
237
+ }, [cancelBlurCommit, suggest.dismiss]);
141
238
 
142
239
  return (
143
240
  <div className="relative" data-address-field={label}>
@@ -171,8 +268,7 @@ export const ComposeAddressField = ({
171
268
  value={inputValue}
172
269
  onChange={(e) => {
173
270
  suggest.reopen();
174
- setInputValue(e.target.value);
175
- onQueryChange?.(e.target.value);
271
+ setInput(e.target.value);
176
272
  }}
177
273
  onKeyDown={handleKeyDown}
178
274
  onBlur={handleBlur}
@@ -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",
package/src/index.ts CHANGED
@@ -171,8 +171,11 @@ export {
171
171
  } from "./components/compose-action-bar.js";
172
172
  export {
173
173
  type AddressEntry,
174
+ type ComposeAddressCommit,
174
175
  ComposeAddressField,
176
+ type ComposeAddressFieldHandle,
175
177
  type ComposeAddressFieldProps,
178
+ type ParsedAddressInput,
176
179
  } from "./components/compose-address-field.js";
177
180
  export { ComposeBodySkeleton } from "./components/compose-body-skeleton.js";
178
181
  export {
@@ -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
 
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseAddressInput } from "./parse-address-input.js";
4
+
5
+ describe("parseAddressInput", () => {
6
+ it("reads a bare address", () => {
7
+ assert.deepEqual(parseAddressInput("ada@example.com"), {
8
+ entries: [{ email: "ada@example.com" }],
9
+ unparsed: "",
10
+ });
11
+ });
12
+
13
+ it("reads a display name and the address it wraps", () => {
14
+ assert.deepEqual(parseAddressInput("Ada Lovelace <ada@example.com>"), {
15
+ entries: [{ email: "ada@example.com", displayName: "Ada Lovelace" }],
16
+ unparsed: "",
17
+ });
18
+ });
19
+
20
+ it("reads a pasted list", () => {
21
+ assert.deepEqual(
22
+ parseAddressInput("alice@example.com, bob@example.com").entries,
23
+ [{ email: "alice@example.com" }, { email: "bob@example.com" }],
24
+ );
25
+ });
26
+
27
+ it("takes a semicolon as a separator too", () => {
28
+ assert.equal(
29
+ parseAddressInput("alice@example.com; bob@example.com").entries.length,
30
+ 2,
31
+ );
32
+ });
33
+
34
+ it("keeps a quoted display name whole", () => {
35
+ assert.deepEqual(parseAddressInput('"Hopper, Grace" <grace@example.com>'), {
36
+ entries: [{ email: "grace@example.com", displayName: "Hopper, Grace" }],
37
+ unparsed: "",
38
+ });
39
+ });
40
+
41
+ it("keeps what is not an address rather than dropping it", () => {
42
+ assert.deepEqual(parseAddressInput("not-an-address"), {
43
+ entries: [],
44
+ unparsed: "not-an-address",
45
+ });
46
+ });
47
+
48
+ it("separates the addresses in a list from what is not one", () => {
49
+ assert.deepEqual(parseAddressInput("bob@example.com, alice@example"), {
50
+ entries: [{ email: "bob@example.com" }],
51
+ unparsed: "alice@example",
52
+ });
53
+ });
54
+
55
+ it("reports every leftover, not only the first", () => {
56
+ assert.equal(
57
+ parseAddressInput("one, bob@example.com, two").unparsed,
58
+ "one, two",
59
+ );
60
+ });
61
+
62
+ it("reads empty text as holding nothing", () => {
63
+ assert.deepEqual(parseAddressInput(" "), { entries: [], unparsed: "" });
64
+ });
65
+
66
+ it("ignores the separators an empty slot leaves behind", () => {
67
+ assert.deepEqual(parseAddressInput("ada@example.com,,"), {
68
+ entries: [{ email: "ada@example.com" }],
69
+ unparsed: "",
70
+ });
71
+ });
72
+
73
+ it("rejects an address with no top-level domain", () => {
74
+ assert.deepEqual(parseAddressInput("ada@example"), {
75
+ entries: [],
76
+ unparsed: "ada@example",
77
+ });
78
+ });
79
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * What a recipient field is holding in its text input, read as addresses.
3
+ *
4
+ * A field takes one address at a time when it is typed, and a whole list when
5
+ * one is pasted, so what is in it is a list either way. Anything in that list
6
+ * that is not an address is kept rather than dropped: it is what the reader
7
+ * typed, and losing it silently is how a message goes out to fewer people than
8
+ * it was addressed to.
9
+ */
10
+
11
+ export interface AddressEntry {
12
+ email: string;
13
+ displayName?: string;
14
+ }
15
+
16
+ export interface ParsedAddressInput {
17
+ /** The addresses the text names, in the order it names them. */
18
+ entries: AddressEntry[];
19
+ /** The parts that are not addresses, rejoined. Empty when there are none. */
20
+ unparsed: string;
21
+ }
22
+
23
+ const isValidEmail = (value: string): boolean =>
24
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
25
+
26
+ const parseOne = (value: string): AddressEntry | undefined => {
27
+ const trimmed = value.trim();
28
+ if (!trimmed) return undefined;
29
+
30
+ const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
31
+ if (angleMatch) {
32
+ const displayName = angleMatch[1].trim().replace(/^"(.*)"$/, "$1");
33
+ const email = angleMatch[2].trim();
34
+ if (isValidEmail(email)) return { email, displayName };
35
+ }
36
+
37
+ if (isValidEmail(trimmed)) return { email: trimmed };
38
+ return undefined;
39
+ };
40
+
41
+ /**
42
+ * Split on the separators a mail client writes between addresses, ignoring the
43
+ * ones inside a quoted display name or an angle-bracketed address — `"Hopper,
44
+ * Grace" <grace@example.com>` is one address, not two.
45
+ */
46
+ const splitAddresses = (value: string): string[] => {
47
+ const parts: string[] = [];
48
+ let current = "";
49
+ let quoted = false;
50
+ let angled = false;
51
+
52
+ for (const char of value) {
53
+ if (char === '"') quoted = !quoted;
54
+ if (char === "<") angled = true;
55
+ if (char === ">") angled = false;
56
+ if ((char === "," || char === ";") && !quoted && !angled) {
57
+ parts.push(current);
58
+ current = "";
59
+ continue;
60
+ }
61
+ current += char;
62
+ }
63
+ parts.push(current);
64
+ return parts;
65
+ };
66
+
67
+ export const parseAddressInput = (value: string): ParsedAddressInput => {
68
+ const entries: AddressEntry[] = [];
69
+ const leftovers: string[] = [];
70
+
71
+ for (const part of splitAddresses(value)) {
72
+ const entry = parseOne(part);
73
+ if (entry) {
74
+ entries.push(entry);
75
+ continue;
76
+ }
77
+ if (part.trim()) leftovers.push(part.trim());
78
+ }
79
+
80
+ return { entries, unparsed: leftovers.join(", ") };
81
+ };