@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,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 {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
65
|
-
onQueryChange?.("");
|
|
120
|
+
setInput("");
|
|
66
121
|
},
|
|
67
|
-
[addresses, existingEmails, onChange,
|
|
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
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
231
|
+
cancelBlurCommit();
|
|
232
|
+
blurCommitRef.current = setTimeout(() => {
|
|
233
|
+
blurCommitRef.current = undefined;
|
|
234
|
+
latestCommitRef.current();
|
|
138
235
|
suggest.dismiss();
|
|
139
|
-
},
|
|
140
|
-
}, [
|
|
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
|
-
|
|
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
|
+
};
|