@remit/ui 0.0.142 → 0.0.143

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.143",
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}
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 {
@@ -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
+ };