@handled-ai/design-system 0.18.42 → 0.18.44
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/dist/components/email-recipient-field.d.ts +4 -6
- package/dist/components/email-recipient-field.js +142 -181
- package/dist/components/email-recipient-field.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/email-recipient-field.test.tsx +208 -45
- package/src/components/email-recipient-field.tsx +109 -130
|
@@ -21,14 +21,12 @@ interface EmailRecipientFieldProps {
|
|
|
21
21
|
placeholder?: string;
|
|
22
22
|
contactToRecipient?: (contact: SuggestedContact) => RecipientChip;
|
|
23
23
|
/**
|
|
24
|
-
* Async search
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* result set rather than a static list to filter client-side. When omitted,
|
|
28
|
-
* the picker filters the static `contacts` array locally (default behavior).
|
|
24
|
+
* Async search mode. When provided, the picker forwards the typed query to
|
|
25
|
+
* this callback (the caller debounces + fetches) and treats `contacts` as the
|
|
26
|
+
* already server-filtered result set instead of filtering it client-side.
|
|
29
27
|
*/
|
|
30
28
|
onSearch?: (query: string) => void;
|
|
31
|
-
/** Shows a
|
|
29
|
+
/** Shows a "Searching contacts..." indicator while async results load. */
|
|
32
30
|
searchLoading?: boolean;
|
|
33
31
|
}
|
|
34
32
|
declare function EmailRecipientField({ label, recipients, onRecipientsChange, amber, contacts, showPicker, showCcBcc, ccBccOpen, onCcBccToggle, addedEmails, placeholder, contactToRecipient, onSearch, searchLoading, }: EmailRecipientFieldProps): React.JSX.Element;
|
|
@@ -20,11 +20,10 @@ var __spreadValues = (a, b) => {
|
|
|
20
20
|
return a;
|
|
21
21
|
};
|
|
22
22
|
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
|
23
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
23
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
24
24
|
import * as React from "react";
|
|
25
|
-
import {
|
|
25
|
+
import { Popover as PopoverPrimitive } from "radix-ui";
|
|
26
26
|
import {
|
|
27
|
-
Check,
|
|
28
27
|
ChevronDown,
|
|
29
28
|
CornerDownLeft,
|
|
30
29
|
Plus,
|
|
@@ -50,16 +49,21 @@ function RecipientChipPill({
|
|
|
50
49
|
onConfirm,
|
|
51
50
|
onRemove
|
|
52
51
|
}) {
|
|
53
|
-
const
|
|
52
|
+
const primary = recipient.name || recipient.email;
|
|
53
|
+
const secondary = recipient.name ? recipient.email : "";
|
|
54
|
+
const display = primary || recipient.email;
|
|
54
55
|
if (!recipient.confirmed) {
|
|
55
|
-
return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1
|
|
56
|
-
/* @__PURE__ */
|
|
56
|
+
return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800", children: [
|
|
57
|
+
/* @__PURE__ */ jsxs("span", { className: "min-w-0 inline-flex items-baseline gap-1", children: [
|
|
58
|
+
/* @__PURE__ */ jsx("span", { className: "truncate font-medium max-w-[150px]", children: primary }),
|
|
59
|
+
secondary ? /* @__PURE__ */ jsx("span", { className: "truncate max-w-[190px] text-amber-800/70", children: secondary }) : null
|
|
60
|
+
] }),
|
|
57
61
|
/* @__PURE__ */ jsx(
|
|
58
62
|
"button",
|
|
59
63
|
{
|
|
60
64
|
type: "button",
|
|
61
65
|
onClick: onConfirm,
|
|
62
|
-
className: "
|
|
66
|
+
className: "ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200",
|
|
63
67
|
children: "Confirm"
|
|
64
68
|
}
|
|
65
69
|
),
|
|
@@ -69,93 +73,38 @@ function RecipientChipPill({
|
|
|
69
73
|
type: "button",
|
|
70
74
|
"aria-label": `Remove ${display}`,
|
|
71
75
|
onClick: onRemove,
|
|
72
|
-
className: "inline-flex items-center justify-center
|
|
76
|
+
className: "inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70",
|
|
73
77
|
children: /* @__PURE__ */ jsx(X, { className: "size-3" })
|
|
74
78
|
}
|
|
75
79
|
)
|
|
76
80
|
] });
|
|
77
81
|
}
|
|
78
|
-
return /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1
|
|
79
|
-
/* @__PURE__ */
|
|
80
|
-
|
|
82
|
+
return /* @__PURE__ */ jsxs("span", { className: "inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground", children: [
|
|
83
|
+
/* @__PURE__ */ jsxs("span", { className: "min-w-0 inline-flex items-baseline gap-1", children: [
|
|
84
|
+
/* @__PURE__ */ jsx("span", { className: "truncate font-medium max-w-[150px]", children: primary }),
|
|
85
|
+
secondary ? /* @__PURE__ */ jsx("span", { className: "truncate max-w-[190px] text-muted-foreground", children: secondary }) : null
|
|
86
|
+
] }),
|
|
81
87
|
/* @__PURE__ */ jsx(
|
|
82
88
|
"button",
|
|
83
89
|
{
|
|
84
90
|
type: "button",
|
|
85
91
|
"aria-label": `Remove ${display}`,
|
|
86
92
|
onClick: onRemove,
|
|
87
|
-
className: "inline-flex items-center justify-center
|
|
93
|
+
className: "inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
88
94
|
children: /* @__PURE__ */ jsx(X, { className: "size-3" })
|
|
89
95
|
}
|
|
90
96
|
)
|
|
91
97
|
] });
|
|
92
98
|
}
|
|
93
|
-
function
|
|
94
|
-
triggerRef,
|
|
99
|
+
function ContactPickerContents({
|
|
95
100
|
contacts,
|
|
96
101
|
addedEmails,
|
|
97
102
|
onSelect,
|
|
98
103
|
onAddEmail,
|
|
99
|
-
onClose,
|
|
100
104
|
onSearch,
|
|
101
105
|
searchLoading = false
|
|
102
106
|
}) {
|
|
103
|
-
const containerRef = React.useRef(null);
|
|
104
|
-
const searchRef = React.useRef(null);
|
|
105
107
|
const [query, setQuery] = React.useState("");
|
|
106
|
-
const [style, setStyle] = React.useState({
|
|
107
|
-
position: "fixed",
|
|
108
|
-
top: -9999,
|
|
109
|
-
left: -9999
|
|
110
|
-
});
|
|
111
|
-
React.useEffect(() => {
|
|
112
|
-
const trigger = triggerRef.current;
|
|
113
|
-
if (!trigger) return;
|
|
114
|
-
const rect = trigger.getBoundingClientRect();
|
|
115
|
-
const width = Math.min(448, window.innerWidth - 32);
|
|
116
|
-
let left = rect.left;
|
|
117
|
-
if (left + width > window.innerWidth - 16) {
|
|
118
|
-
left = window.innerWidth - 16 - width;
|
|
119
|
-
}
|
|
120
|
-
if (left < 16) left = 16;
|
|
121
|
-
const popoverHeight = 280;
|
|
122
|
-
const spaceBelow = window.innerHeight - rect.bottom - 4;
|
|
123
|
-
const spaceAbove = rect.top - 4;
|
|
124
|
-
const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow;
|
|
125
|
-
let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4;
|
|
126
|
-
if (top < 16) top = 16;
|
|
127
|
-
setStyle({ position: "fixed", top, left, width });
|
|
128
|
-
}, [triggerRef]);
|
|
129
|
-
React.useEffect(() => {
|
|
130
|
-
var _a;
|
|
131
|
-
(_a = searchRef.current) == null ? void 0 : _a.focus();
|
|
132
|
-
const trigger = triggerRef.current;
|
|
133
|
-
return () => {
|
|
134
|
-
if (trigger && typeof trigger.focus === "function") {
|
|
135
|
-
trigger.focus();
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
|
-
}, [triggerRef]);
|
|
139
|
-
React.useEffect(() => {
|
|
140
|
-
function handleMouseDown(event) {
|
|
141
|
-
var _a;
|
|
142
|
-
if (containerRef.current && !containerRef.current.contains(event.target) && !((_a = triggerRef.current) == null ? void 0 : _a.contains(event.target))) {
|
|
143
|
-
onClose();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
function handleKeyDown2(event) {
|
|
147
|
-
if (event.key === "Escape") {
|
|
148
|
-
event.stopPropagation();
|
|
149
|
-
onClose();
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
document.addEventListener("mousedown", handleMouseDown);
|
|
153
|
-
document.addEventListener("keydown", handleKeyDown2);
|
|
154
|
-
return () => {
|
|
155
|
-
document.removeEventListener("mousedown", handleMouseDown);
|
|
156
|
-
document.removeEventListener("keydown", handleKeyDown2);
|
|
157
|
-
};
|
|
158
|
-
}, [onClose, triggerRef]);
|
|
159
108
|
const asyncMode = typeof onSearch === "function";
|
|
160
109
|
const onSearchRef = React.useRef(onSearch);
|
|
161
110
|
React.useEffect(() => {
|
|
@@ -181,82 +130,73 @@ function ContactPickerPopover({
|
|
|
181
130
|
setQuery("");
|
|
182
131
|
}
|
|
183
132
|
}
|
|
184
|
-
return
|
|
185
|
-
/* @__PURE__ */ jsxs(
|
|
186
|
-
"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
133
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
134
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
|
|
135
|
+
/* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
|
|
136
|
+
/* @__PURE__ */ jsx(
|
|
137
|
+
"input",
|
|
138
|
+
{
|
|
139
|
+
autoFocus: true,
|
|
140
|
+
value: query,
|
|
141
|
+
onChange: (event) => setQuery(event.target.value),
|
|
142
|
+
onKeyDown: handleKeyDown,
|
|
143
|
+
className: "flex-1 text-[13px] bg-transparent outline-none",
|
|
144
|
+
placeholder: "Search contacts..."
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
] }),
|
|
148
|
+
/* @__PURE__ */ jsx("div", { role: "listbox", className: "max-h-[208px] overflow-y-auto p-1", children: searchLoading ? /* @__PURE__ */ jsx("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: "Searching contacts..." }) : asyncMode && normalizedQuery.length === 0 ? /* @__PURE__ */ jsx("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: "Type a name or email to search contacts." }) : !asyncMode && contacts.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-5 text-center text-[13px] text-muted-foreground", children: [
|
|
149
|
+
/* @__PURE__ */ jsx("div", { className: "font-medium text-foreground/80", children: "No contacts for this account" }),
|
|
150
|
+
/* @__PURE__ */ jsx("div", { className: "mt-1", children: "Type an email address above and press Enter to add a recipient." })
|
|
151
|
+
] }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: [
|
|
152
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
153
|
+
"No contact matches \u2018",
|
|
154
|
+
query,
|
|
155
|
+
"\u2019."
|
|
156
|
+
] }),
|
|
157
|
+
queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
|
|
158
|
+
"Press Enter to add ",
|
|
159
|
+
query,
|
|
160
|
+
"."
|
|
161
|
+
] }) : null
|
|
162
|
+
] }) : filtered.map((contact, index) => {
|
|
163
|
+
const email = contactEmail(contact);
|
|
164
|
+
const noEmail = !email || !isValidEmail(email);
|
|
165
|
+
const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
|
|
166
|
+
const disabled = noEmail || alreadyAdded;
|
|
167
|
+
return /* @__PURE__ */ jsxs(
|
|
168
|
+
"div",
|
|
169
|
+
{
|
|
170
|
+
role: "option",
|
|
171
|
+
"aria-selected": false,
|
|
172
|
+
"aria-disabled": disabled,
|
|
173
|
+
onClick: () => {
|
|
174
|
+
if (!disabled) onSelect(contact);
|
|
175
|
+
},
|
|
176
|
+
className: cn(
|
|
177
|
+
"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
|
|
178
|
+
disabled && "opacity-45 pointer-events-none"
|
|
179
|
+
),
|
|
180
|
+
children: [
|
|
181
|
+
/* @__PURE__ */ jsx("div", { className: "flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground", children: getInitials(contact.name, email != null ? email : "?") }),
|
|
182
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
183
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
|
|
184
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
|
|
185
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
|
|
186
|
+
] }),
|
|
187
|
+
email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
|
|
212
188
|
] }),
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
"div",
|
|
225
|
-
{
|
|
226
|
-
role: "option",
|
|
227
|
-
"aria-selected": false,
|
|
228
|
-
"aria-disabled": disabled,
|
|
229
|
-
onClick: () => {
|
|
230
|
-
if (!disabled) onSelect(contact);
|
|
231
|
-
},
|
|
232
|
-
className: cn(
|
|
233
|
-
"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
|
|
234
|
-
disabled && "opacity-45 pointer-events-none"
|
|
235
|
-
),
|
|
236
|
-
children: [
|
|
237
|
-
/* @__PURE__ */ jsx("div", { className: "flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground", children: getInitials(contact.name, email != null ? email : "?") }),
|
|
238
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
239
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
|
|
240
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
|
|
241
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
|
|
242
|
-
] }),
|
|
243
|
-
email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
|
|
244
|
-
] }),
|
|
245
|
-
alreadyAdded ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "Added" }) : noEmail ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "No email" }) : null
|
|
246
|
-
]
|
|
247
|
-
},
|
|
248
|
-
`${contact.name}-${email != null ? email : index}`
|
|
249
|
-
);
|
|
250
|
-
}) }),
|
|
251
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground", children: [
|
|
252
|
-
/* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
|
|
253
|
-
/* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
|
|
254
|
-
] })
|
|
255
|
-
]
|
|
256
|
-
}
|
|
257
|
-
),
|
|
258
|
-
document.body
|
|
259
|
-
);
|
|
189
|
+
alreadyAdded ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "Added" }) : noEmail ? /* @__PURE__ */ jsx("span", { className: "shrink-0 text-[10.5px] font-medium text-muted-foreground", children: "No email" }) : null
|
|
190
|
+
]
|
|
191
|
+
},
|
|
192
|
+
`${contact.name}-${email != null ? email : index}`
|
|
193
|
+
);
|
|
194
|
+
}) }),
|
|
195
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground", children: [
|
|
196
|
+
/* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
|
|
197
|
+
/* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
|
|
198
|
+
] })
|
|
199
|
+
] });
|
|
260
200
|
}
|
|
261
201
|
function EmailRecipientField({
|
|
262
202
|
label,
|
|
@@ -276,28 +216,36 @@ function EmailRecipientField({
|
|
|
276
216
|
}) {
|
|
277
217
|
const [value, setValue] = React.useState("");
|
|
278
218
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
279
|
-
const contactsTriggerRef = React.useRef(null);
|
|
280
219
|
const hasUnconfirmed = recipients.some((r) => !r.confirmed);
|
|
281
220
|
const state = amber && hasUnconfirmed ? "amber" : "default";
|
|
282
221
|
const amberRow = state === "amber";
|
|
283
222
|
const added = addedEmails != null ? addedEmails : /* @__PURE__ */ new Set();
|
|
284
223
|
const resolvedPlaceholder = placeholder != null ? placeholder : recipients.length > 0 ? "Add another..." : "Add email...";
|
|
224
|
+
const committedByKeyRef = React.useRef(null);
|
|
285
225
|
function addEmail(email) {
|
|
286
226
|
const trimmed = email.trim();
|
|
287
|
-
if (!isValidEmail(trimmed)) return;
|
|
288
|
-
if (added.has(trimmed.toLowerCase())) return;
|
|
227
|
+
if (!isValidEmail(trimmed)) return false;
|
|
228
|
+
if (added.has(trimmed.toLowerCase())) return false;
|
|
289
229
|
onRecipientsChange([
|
|
290
230
|
...recipients,
|
|
291
231
|
{ id: trimmed, email: trimmed, name: "", confirmed: false }
|
|
292
232
|
]);
|
|
293
233
|
setValue("");
|
|
234
|
+
return true;
|
|
294
235
|
}
|
|
295
236
|
function handleKeyDown(event) {
|
|
296
|
-
if ((event.key === "Enter" || event.key === ",") &&
|
|
237
|
+
if ((event.key === "Enter" || event.key === ",") && value.trim()) {
|
|
297
238
|
event.preventDefault();
|
|
298
239
|
addEmail(value);
|
|
299
240
|
return;
|
|
300
241
|
}
|
|
242
|
+
if (event.key === "Tab" && value.trim()) {
|
|
243
|
+
const trimmed = value.trim();
|
|
244
|
+
if (addEmail(trimmed)) {
|
|
245
|
+
committedByKeyRef.current = trimmed.toLowerCase();
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
301
249
|
if (event.key === "Backspace" && value === "" && recipients.length > 0) {
|
|
302
250
|
event.preventDefault();
|
|
303
251
|
onRecipientsChange(recipients.slice(0, -1));
|
|
@@ -358,7 +306,12 @@ function EmailRecipientField({
|
|
|
358
306
|
onChange: (event) => setValue(event.target.value),
|
|
359
307
|
onKeyDown: handleKeyDown,
|
|
360
308
|
onBlur: () => {
|
|
361
|
-
|
|
309
|
+
const trimmed = value.trim().toLowerCase();
|
|
310
|
+
if (trimmed && committedByKeyRef.current === trimmed) {
|
|
311
|
+
committedByKeyRef.current = null;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
addEmail(value);
|
|
362
315
|
},
|
|
363
316
|
placeholder: resolvedPlaceholder,
|
|
364
317
|
className: "min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"
|
|
@@ -366,20 +319,44 @@ function EmailRecipientField({
|
|
|
366
319
|
)
|
|
367
320
|
] }),
|
|
368
321
|
showPicker || showCcBcc ? /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 mt-2", children: [
|
|
369
|
-
showPicker ? /* @__PURE__ */ jsxs(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
}
|
|
382
|
-
|
|
322
|
+
showPicker ? /* @__PURE__ */ jsxs(PopoverPrimitive.Root, { open: pickerOpen, onOpenChange: setPickerOpen, children: [
|
|
323
|
+
/* @__PURE__ */ jsx(PopoverPrimitive.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
324
|
+
"button",
|
|
325
|
+
{
|
|
326
|
+
type: "button",
|
|
327
|
+
className: "inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]",
|
|
328
|
+
children: [
|
|
329
|
+
/* @__PURE__ */ jsx(Users, { className: "size-3" }),
|
|
330
|
+
"Contacts",
|
|
331
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
) }),
|
|
335
|
+
/* @__PURE__ */ jsx(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx(
|
|
336
|
+
PopoverPrimitive.Content,
|
|
337
|
+
{
|
|
338
|
+
side: "bottom",
|
|
339
|
+
align: "start",
|
|
340
|
+
sideOffset: 4,
|
|
341
|
+
collisionPadding: 16,
|
|
342
|
+
className: "z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0",
|
|
343
|
+
children: /* @__PURE__ */ jsx(
|
|
344
|
+
ContactPickerContents,
|
|
345
|
+
{
|
|
346
|
+
contacts,
|
|
347
|
+
addedEmails: added,
|
|
348
|
+
onSelect: selectContact,
|
|
349
|
+
onAddEmail: (email) => {
|
|
350
|
+
addEmail(email);
|
|
351
|
+
setPickerOpen(false);
|
|
352
|
+
},
|
|
353
|
+
onSearch,
|
|
354
|
+
searchLoading
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
) })
|
|
359
|
+
] }) : null,
|
|
383
360
|
showCcBcc ? /* @__PURE__ */ jsxs(
|
|
384
361
|
"button",
|
|
385
362
|
{
|
|
@@ -392,23 +369,7 @@ function EmailRecipientField({
|
|
|
392
369
|
]
|
|
393
370
|
}
|
|
394
371
|
) : null
|
|
395
|
-
] }) : null
|
|
396
|
-
pickerOpen ? /* @__PURE__ */ jsx(
|
|
397
|
-
ContactPickerPopover,
|
|
398
|
-
{
|
|
399
|
-
triggerRef: contactsTriggerRef,
|
|
400
|
-
contacts,
|
|
401
|
-
addedEmails: added,
|
|
402
|
-
onSelect: selectContact,
|
|
403
|
-
onAddEmail: (email) => {
|
|
404
|
-
addEmail(email);
|
|
405
|
-
setPickerOpen(false);
|
|
406
|
-
},
|
|
407
|
-
onClose: () => setPickerOpen(false),
|
|
408
|
-
onSearch,
|
|
409
|
-
searchLoading
|
|
410
|
-
}
|
|
411
|
-
) : null
|
|
372
|
+
] }) : null
|
|
412
373
|
] })
|
|
413
374
|
]
|
|
414
375
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/email-recipient-field.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n Check,\n ChevronDown,\n CornerDownLeft,\n Plus,\n Search,\n Users,\n X,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport type { SuggestedContact } from \"./suggested-actions\"\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value.trim())\n}\n\nfunction contactEmail(contact: SuggestedContact): string | undefined {\n return contact.email ?? contact.emails?.[0]\n}\n\nfunction getInitials(name: string, fallback: string): string {\n const source = name?.trim() || fallback\n return source\n .split(/[\\s@.]+/)\n .map((part) => part[0])\n .filter(Boolean)\n .slice(0, 2)\n .join(\"\")\n .toUpperCase()\n}\n\nexport interface RecipientChip {\n id: string\n email: string\n name: string\n confirmed: boolean\n}\n\nexport interface EmailRecipientFieldProps {\n label: string\n recipients: RecipientChip[]\n onRecipientsChange: (recipients: RecipientChip[]) => void\n amber?: boolean\n contacts?: SuggestedContact[]\n showPicker?: boolean\n showCcBcc?: boolean\n ccBccOpen?: boolean\n onCcBccToggle?: () => void\n addedEmails?: Set<string>\n placeholder?: string\n contactToRecipient?: (contact: SuggestedContact) => RecipientChip\n /**\n * Async search hook. When provided, the contact picker switches to async\n * mode: the search box forwards its query here (the caller is responsible for\n * debouncing and fetching) and `contacts` is treated as the server-filtered\n * result set rather than a static list to filter client-side. When omitted,\n * the picker filters the static `contacts` array locally (default behavior).\n */\n onSearch?: (query: string) => void\n /** Shows a loading indicator in the picker while async results are fetched. */\n searchLoading?: boolean\n}\n\nfunction RecipientChipPill({\n recipient,\n onConfirm,\n onRemove,\n}: {\n recipient: RecipientChip\n onConfirm: () => void\n onRemove: () => void\n}) {\n const display = recipient.name || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-amber-300 bg-amber-50 text-amber-900\">\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"text-[10.5px] font-semibold px-[7px] py-0.5 rounded bg-amber-300/50 hover:bg-amber-300/85\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-amber-700/80 hover:bg-amber-300/40\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex items-center gap-1 h-6 px-2 rounded-md text-xs border border-border bg-muted/50 text-foreground\">\n <span className=\"inline-flex items-center justify-center size-[17px] rounded bg-emerald-50 text-emerald-700\">\n <Check className=\"size-3\" />\n </span>\n <span className=\"truncate max-w-[180px]\">{display}</span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex items-center justify-center size-[17px] rounded text-muted-foreground hover:bg-muted\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n}\n\nfunction ContactPickerPopover({\n triggerRef,\n contacts,\n addedEmails,\n onSelect,\n onAddEmail,\n onClose,\n onSearch,\n searchLoading = false,\n}: {\n triggerRef: React.RefObject<HTMLElement | null>\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n onClose: () => void\n onSearch?: (query: string) => void\n searchLoading?: boolean\n}) {\n const containerRef = React.useRef<HTMLDivElement>(null)\n const searchRef = React.useRef<HTMLInputElement>(null)\n const [query, setQuery] = React.useState(\"\")\n const [style, setStyle] = React.useState<React.CSSProperties>({\n position: \"fixed\",\n top: -9999,\n left: -9999,\n })\n\n React.useEffect(() => {\n const trigger = triggerRef.current\n if (!trigger) return\n const rect = trigger.getBoundingClientRect()\n const width = Math.min(448, window.innerWidth - 32)\n let left = rect.left\n if (left + width > window.innerWidth - 16) {\n left = window.innerWidth - 16 - width\n }\n if (left < 16) left = 16\n const popoverHeight = 280\n const spaceBelow = window.innerHeight - rect.bottom - 4\n const spaceAbove = rect.top - 4\n const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow\n let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4\n if (top < 16) top = 16\n setStyle({ position: \"fixed\", top, left, width })\n }, [triggerRef])\n\n React.useEffect(() => {\n searchRef.current?.focus()\n const trigger = triggerRef.current\n return () => {\n if (trigger && typeof trigger.focus === \"function\") {\n trigger.focus()\n }\n }\n }, [triggerRef])\n\n React.useEffect(() => {\n function handleMouseDown(event: MouseEvent) {\n if (\n containerRef.current &&\n !containerRef.current.contains(event.target as Node) &&\n !triggerRef.current?.contains(event.target as Node)\n ) {\n onClose()\n }\n }\n function handleKeyDown(event: KeyboardEvent) {\n if (event.key === \"Escape\") {\n event.stopPropagation()\n onClose()\n }\n }\n document.addEventListener(\"mousedown\", handleMouseDown)\n document.addEventListener(\"keydown\", handleKeyDown)\n return () => {\n document.removeEventListener(\"mousedown\", handleMouseDown)\n document.removeEventListener(\"keydown\", handleKeyDown)\n }\n }, [onClose, triggerRef])\n\n const asyncMode = typeof onSearch === \"function\"\n\n // Keep the latest onSearch in a ref so the search effect below fires only when\n // the query (or async mode) changes, not when the callback identity changes.\n // This lets callers pass an inline handler without triggering duplicate\n // fetches on unrelated parent re-renders.\n const onSearchRef = React.useRef(onSearch)\n React.useEffect(() => {\n onSearchRef.current = onSearch\n }, [onSearch])\n\n // Async mode: forward the query upward (caller debounces + fetches) and treat\n // `contacts` as the already server-filtered result set. Local mode: filter the\n // static `contacts` array client-side.\n React.useEffect(() => {\n if (asyncMode) {\n onSearchRef.current?.(query)\n }\n }, [asyncMode, query])\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = asyncMode\n ? contacts\n : normalizedQuery\n ? contacts.filter((contact) => {\n const email = contactEmail(contact) ?? \"\"\n return (\n contact.name.toLowerCase().includes(normalizedQuery) ||\n contact.role.toLowerCase().includes(normalizedQuery) ||\n email.toLowerCase().includes(normalizedQuery)\n )\n })\n : contacts\n\n const queryIsEmail = isValidEmail(query)\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"Enter\" && queryIsEmail) {\n event.preventDefault()\n onAddEmail(query.trim())\n setQuery(\"\")\n }\n }\n\n return createPortal(\n <div\n ref={containerRef}\n style={style}\n className=\"bg-background border rounded-lg shadow-xl z-50 pointer-events-auto\"\n >\n <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border/50\">\n <Search className=\"size-4 text-muted-foreground shrink-0\" />\n <input\n ref={searchRef}\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n className=\"flex-1 text-[13px] bg-transparent outline-none\"\n placeholder=\"Search contacts...\"\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-[208px] overflow-y-auto p-1\">\n {searchLoading ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n Searching contacts...\n </div>\n ) : filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n {asyncMode && normalizedQuery.length === 0 ? (\n <div>Type a name or email to search contacts.</div>\n ) : (\n <div>No contact matches ‘{query}’.</div>\n )}\n {queryIsEmail ? (\n <div className=\"mt-1\">Press Enter to add {query}.</div>\n ) : null}\n </div>\n ) : (\n filtered.map((contact, index) => {\n const email = contactEmail(contact)\n const noEmail = !email || !isValidEmail(email)\n const alreadyAdded = email\n ? addedEmails.has(email.toLowerCase())\n : false\n const disabled = noEmail || alreadyAdded\n\n return (\n <div\n key={`${contact.name}-${email ?? index}`}\n role=\"option\"\n aria-selected={false}\n aria-disabled={disabled}\n onClick={() => {\n if (!disabled) onSelect(contact)\n }}\n className={cn(\n \"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60\",\n disabled && \"opacity-45 pointer-events-none\",\n )}\n >\n <div className=\"flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground\">\n {getInitials(contact.name, email ?? \"?\")}\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-[13px] font-medium text-foreground\">\n {contact.name}\n </span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {contact.role}\n </span>\n </div>\n {email ? (\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {email}\n </div>\n ) : null}\n </div>\n {alreadyAdded ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n Added\n </span>\n ) : noEmail ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n No email\n </span>\n ) : null}\n </div>\n )\n })\n )}\n </div>\n\n <div className=\"flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground\">\n <CornerDownLeft className=\"size-3 shrink-0\" />\n <span>Type an address and press Enter to add someone not listed.</span>\n </div>\n </div>,\n document.body,\n )\n}\n\nexport function EmailRecipientField({\n label,\n recipients,\n onRecipientsChange,\n amber = false,\n contacts = [],\n showPicker = false,\n showCcBcc = false,\n ccBccOpen = false,\n onCcBccToggle,\n addedEmails,\n placeholder,\n contactToRecipient,\n onSearch,\n searchLoading,\n}: EmailRecipientFieldProps) {\n const [value, setValue] = React.useState(\"\")\n const [pickerOpen, setPickerOpen] = React.useState(false)\n const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)\n\n const hasUnconfirmed = recipients.some((r) => !r.confirmed)\n const state: \"default\" | \"amber\" =\n amber && hasUnconfirmed ? \"amber\" : \"default\"\n const amberRow = state === \"amber\"\n\n const added = addedEmails ?? new Set<string>()\n\n const resolvedPlaceholder =\n placeholder ?? (recipients.length > 0 ? \"Add another...\" : \"Add email...\")\n\n function addEmail(email: string) {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return\n if (added.has(trimmed.toLowerCase())) return\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && isValidEmail(value)) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Backspace\" && value === \"\" && recipients.length > 0) {\n event.preventDefault()\n onRecipientsChange(recipients.slice(0, -1))\n }\n }\n\n function confirmRecipient(id: string) {\n onRecipientsChange(\n recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),\n )\n }\n\n function removeRecipient(id: string) {\n onRecipientsChange(recipients.filter((r) => r.id !== id))\n }\n\n function selectContact(contact: SuggestedContact) {\n const recipient =\n contactToRecipient?.(contact) ??\n ({\n id: contactEmail(contact) ?? contact.name,\n email: contactEmail(contact) ?? \"\",\n name: contact.name,\n confirmed: true,\n } satisfies RecipientChip)\n onRecipientsChange([...recipients, recipient])\n setPickerOpen(false)\n }\n\n return (\n <div\n className={cn(\n \"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm\",\n amberRow && \"bg-amber-50/35 border-amber-200/80\",\n )}\n >\n <div\n className={cn(\n \"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]\",\n amberRow && \"text-amber-700\",\n )}\n >\n {label}\n </div>\n\n <div className=\"min-w-0\">\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {recipients.map((recipient) => (\n <RecipientChipPill\n key={recipient.id}\n recipient={recipient}\n onConfirm={() => confirmRecipient(recipient.id)}\n onRemove={() => removeRecipient(recipient.id)}\n />\n ))}\n <input\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n if (isValidEmail(value)) addEmail(value)\n }}\n placeholder={resolvedPlaceholder}\n className=\"min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground\"\n />\n </div>\n\n {showPicker || showCcBcc ? (\n <div className=\"flex gap-1.5 mt-2\">\n {showPicker ? (\n <button\n ref={contactsTriggerRef}\n type=\"button\"\n onClick={() => setPickerOpen((open) => !open)}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Users className=\"size-3\" />\n Contacts\n <ChevronDown className=\"size-3\" />\n </button>\n ) : null}\n {showCcBcc ? (\n <button\n type=\"button\"\n onClick={onCcBccToggle}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Plus className=\"size-3\" />\n {ccBccOpen ? \"Hide Cc/Bcc\" : \"Add Cc/Bcc\"}\n </button>\n ) : null}\n </div>\n ) : null}\n\n {pickerOpen ? (\n <ContactPickerPopover\n triggerRef={contactsTriggerRef}\n contacts={contacts}\n addedEmails={added}\n onSelect={selectContact}\n onAddEmail={(email) => {\n addEmail(email)\n setPickerOpen(false)\n }}\n onClose={() => setPickerOpen(false)}\n onSearch={onSearch}\n searchLoading={searchLoading}\n />\n ) : null}\n </div>\n </div>\n )\n}\n"],"mappings":"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","names":["handleKeyDown"]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/email-recipient-field.tsx"],"sourcesContent":["\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\nimport {\n ChevronDown,\n CornerDownLeft,\n Plus,\n Search,\n Users,\n X,\n} from \"lucide-react\"\n\nimport { cn } from \"../lib/utils\"\nimport type { SuggestedContact } from \"./suggested-actions\"\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value.trim())\n}\n\nfunction contactEmail(contact: SuggestedContact): string | undefined {\n return contact.email ?? contact.emails?.[0]\n}\n\nfunction getInitials(name: string, fallback: string): string {\n const source = name?.trim() || fallback\n return source\n .split(/[\\s@.]+/)\n .map((part) => part[0])\n .filter(Boolean)\n .slice(0, 2)\n .join(\"\")\n .toUpperCase()\n}\n\nexport interface RecipientChip {\n id: string\n email: string\n name: string\n confirmed: boolean\n}\n\nexport interface EmailRecipientFieldProps {\n label: string\n recipients: RecipientChip[]\n onRecipientsChange: (recipients: RecipientChip[]) => void\n amber?: boolean\n contacts?: SuggestedContact[]\n showPicker?: boolean\n showCcBcc?: boolean\n ccBccOpen?: boolean\n onCcBccToggle?: () => void\n addedEmails?: Set<string>\n placeholder?: string\n contactToRecipient?: (contact: SuggestedContact) => RecipientChip\n /**\n * Async search mode. When provided, the picker forwards the typed query to\n * this callback (the caller debounces + fetches) and treats `contacts` as the\n * already server-filtered result set instead of filtering it client-side.\n */\n onSearch?: (query: string) => void\n /** Shows a \"Searching contacts...\" indicator while async results load. */\n searchLoading?: boolean\n}\n\nfunction RecipientChipPill({\n recipient,\n onConfirm,\n onRemove,\n}: {\n recipient: RecipientChip\n onConfirm: () => void\n onRemove: () => void\n}) {\n const primary = recipient.name || recipient.email\n const secondary = recipient.name ? recipient.email : \"\"\n const display = primary || recipient.email\n\n if (!recipient.confirmed) {\n return (\n <span className=\"inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800\">\n <span className=\"min-w-0 inline-flex items-baseline gap-1\">\n <span className=\"truncate font-medium max-w-[150px]\">{primary}</span>\n {secondary ? (\n <span className=\"truncate max-w-[190px] text-amber-800/70\">\n {secondary}\n </span>\n ) : null}\n </span>\n <button\n type=\"button\"\n onClick={onConfirm}\n className=\"ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200\"\n >\n Confirm\n </button>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n }\n\n return (\n <span className=\"inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground\">\n <span className=\"min-w-0 inline-flex items-baseline gap-1\">\n <span className=\"truncate font-medium max-w-[150px]\">{primary}</span>\n {secondary ? (\n <span className=\"truncate max-w-[190px] text-muted-foreground\">\n {secondary}\n </span>\n ) : null}\n </span>\n <button\n type=\"button\"\n aria-label={`Remove ${display}`}\n onClick={onRemove}\n className=\"inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n >\n <X className=\"size-3\" />\n </button>\n </span>\n )\n}\n\n// Contents of the contact picker dropdown. Rendered inside a Radix\n// `Popover.Content` so its focus scope pushes onto the focus-scope stack and\n// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what\n// makes the search input typeable: a plain `createPortal(..., document.body)`\n// element renders outside the Dialog's `DialogContent`, so the Dialog's\n// FocusScope kept yanking focus back (input un-typeable) and its modal\n// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix\n// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus\n// scope, fixing both. See WIT-800 / WIT-770.\nfunction ContactPickerContents({\n contacts,\n addedEmails,\n onSelect,\n onAddEmail,\n onSearch,\n searchLoading = false,\n}: {\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n onSearch?: (query: string) => void\n searchLoading?: boolean\n}) {\n const [query, setQuery] = React.useState(\"\")\n\n const asyncMode = typeof onSearch === \"function\"\n\n // Keep the latest onSearch in a ref so the search effect below fires only when\n // the query (or async mode) changes, not when the callback identity changes.\n // This lets callers pass an inline handler without triggering duplicate\n // fetches on unrelated parent re-renders.\n const onSearchRef = React.useRef(onSearch)\n React.useEffect(() => {\n onSearchRef.current = onSearch\n }, [onSearch])\n\n // Async mode: forward the query upward (caller debounces + fetches) and treat\n // `contacts` as the already server-filtered result set. Local mode: filter the\n // static `contacts` array client-side.\n React.useEffect(() => {\n if (asyncMode) {\n onSearchRef.current?.(query)\n }\n }, [asyncMode, query])\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = asyncMode\n ? contacts\n : normalizedQuery\n ? contacts.filter((contact) => {\n const email = contactEmail(contact) ?? \"\"\n return (\n contact.name.toLowerCase().includes(normalizedQuery) ||\n contact.role.toLowerCase().includes(normalizedQuery) ||\n email.toLowerCase().includes(normalizedQuery)\n )\n })\n : contacts\n\n const queryIsEmail = isValidEmail(query)\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if (event.key === \"Enter\" && queryIsEmail) {\n event.preventDefault()\n onAddEmail(query.trim())\n setQuery(\"\")\n }\n }\n\n return (\n <>\n <div className=\"flex items-center gap-2 px-3 py-2.5 border-b border-border/50\">\n <Search className=\"size-4 text-muted-foreground shrink-0\" />\n <input\n autoFocus\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n onKeyDown={handleKeyDown}\n className=\"flex-1 text-[13px] bg-transparent outline-none\"\n placeholder=\"Search contacts...\"\n />\n </div>\n\n <div role=\"listbox\" className=\"max-h-[208px] overflow-y-auto p-1\">\n {searchLoading ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Searching contacts...\n </div>\n ) : asyncMode && normalizedQuery.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n Type a name or email to search contacts.\n </div>\n ) : !asyncMode && contacts.length === 0 ? (\n <div className=\"px-3 py-5 text-center text-[13px] text-muted-foreground\">\n <div className=\"font-medium text-foreground/80\">\n No contacts for this account\n </div>\n <div className=\"mt-1\">\n Type an email address above and press Enter to add a recipient.\n </div>\n </div>\n ) : filtered.length === 0 ? (\n <div className=\"px-3 py-4 text-center text-[13px] text-muted-foreground\">\n <div>No contact matches ‘{query}’.</div>\n {queryIsEmail ? (\n <div className=\"mt-1\">Press Enter to add {query}.</div>\n ) : null}\n </div>\n ) : (\n filtered.map((contact, index) => {\n const email = contactEmail(contact)\n const noEmail = !email || !isValidEmail(email)\n const alreadyAdded = email\n ? addedEmails.has(email.toLowerCase())\n : false\n const disabled = noEmail || alreadyAdded\n\n return (\n <div\n key={`${contact.name}-${email ?? index}`}\n role=\"option\"\n aria-selected={false}\n aria-disabled={disabled}\n onClick={() => {\n if (!disabled) onSelect(contact)\n }}\n className={cn(\n \"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60\",\n disabled && \"opacity-45 pointer-events-none\",\n )}\n >\n <div className=\"flex size-7 shrink-0 items-center justify-center rounded-[7px] bg-muted text-[11px] font-medium text-muted-foreground\">\n {getInitials(contact.name, email ?? \"?\")}\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-[13px] font-medium text-foreground\">\n {contact.name}\n </span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {contact.role}\n </span>\n </div>\n {email ? (\n <div className=\"truncate text-[11px] text-muted-foreground\">\n {email}\n </div>\n ) : null}\n </div>\n {alreadyAdded ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n Added\n </span>\n ) : noEmail ? (\n <span className=\"shrink-0 text-[10.5px] font-medium text-muted-foreground\">\n No email\n </span>\n ) : null}\n </div>\n )\n })\n )}\n </div>\n\n <div className=\"flex items-center gap-1.5 px-3 py-2 border-t border-border/50 text-[11px] text-muted-foreground\">\n <CornerDownLeft className=\"size-3 shrink-0\" />\n <span>Type an address and press Enter to add someone not listed.</span>\n </div>\n </>\n )\n}\n\nexport function EmailRecipientField({\n label,\n recipients,\n onRecipientsChange,\n amber = false,\n contacts = [],\n showPicker = false,\n showCcBcc = false,\n ccBccOpen = false,\n onCcBccToggle,\n addedEmails,\n placeholder,\n contactToRecipient,\n onSearch,\n searchLoading,\n}: EmailRecipientFieldProps) {\n const [value, setValue] = React.useState(\"\")\n const [pickerOpen, setPickerOpen] = React.useState(false)\n\n const hasUnconfirmed = recipients.some((r) => !r.confirmed)\n const state: \"default\" | \"amber\" =\n amber && hasUnconfirmed ? \"amber\" : \"default\"\n const amberRow = state === \"amber\"\n\n const added = addedEmails ?? new Set<string>()\n\n const resolvedPlaceholder =\n placeholder ?? (recipients.length > 0 ? \"Add another...\" : \"Add email...\")\n\n const committedByKeyRef = React.useRef<string | null>(null)\n\n function addEmail(email: string): boolean {\n const trimmed = email.trim()\n if (!isValidEmail(trimmed)) return false\n if (added.has(trimmed.toLowerCase())) return false\n onRecipientsChange([\n ...recipients,\n { id: trimmed, email: trimmed, name: \"\", confirmed: false },\n ])\n setValue(\"\")\n return true\n }\n\n function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {\n if ((event.key === \"Enter\" || event.key === \",\") && value.trim()) {\n event.preventDefault()\n addEmail(value)\n return\n }\n if (event.key === \"Tab\" && value.trim()) {\n const trimmed = value.trim()\n if (addEmail(trimmed)) {\n committedByKeyRef.current = trimmed.toLowerCase()\n }\n return\n }\n if (event.key === \"Backspace\" && value === \"\" && recipients.length > 0) {\n event.preventDefault()\n onRecipientsChange(recipients.slice(0, -1))\n }\n }\n\n function confirmRecipient(id: string) {\n onRecipientsChange(\n recipients.map((r) => (r.id === id ? { ...r, confirmed: true } : r)),\n )\n }\n\n function removeRecipient(id: string) {\n onRecipientsChange(recipients.filter((r) => r.id !== id))\n }\n\n function selectContact(contact: SuggestedContact) {\n const recipient =\n contactToRecipient?.(contact) ??\n ({\n id: contactEmail(contact) ?? contact.name,\n email: contactEmail(contact) ?? \"\",\n name: contact.name,\n confirmed: true,\n } satisfies RecipientChip)\n onRecipientsChange([...recipients, recipient])\n setPickerOpen(false)\n }\n\n return (\n <div\n className={cn(\n \"grid grid-cols-[60px_1fr] gap-2 px-[18px] py-[9px] border-b border-border/70 items-start text-sm\",\n amberRow && \"bg-amber-50/35 border-amber-200/80\",\n )}\n >\n <div\n className={cn(\n \"text-[11px] font-semibold uppercase tracking-wide text-muted-foreground pt-[7px]\",\n amberRow && \"text-amber-700\",\n )}\n >\n {label}\n </div>\n\n <div className=\"min-w-0\">\n <div className=\"flex flex-wrap gap-1.5 items-center\">\n {recipients.map((recipient) => (\n <RecipientChipPill\n key={recipient.id}\n recipient={recipient}\n onConfirm={() => confirmRecipient(recipient.id)}\n onRemove={() => removeRecipient(recipient.id)}\n />\n ))}\n <input\n value={value}\n onChange={(event) => setValue(event.target.value)}\n onKeyDown={handleKeyDown}\n onBlur={() => {\n const trimmed = value.trim().toLowerCase()\n if (trimmed && committedByKeyRef.current === trimmed) {\n committedByKeyRef.current = null\n return\n }\n // Commit any valid pending email so it is not silently dropped\n // when the user clicks Send without pressing Enter/comma first.\n addEmail(value)\n }}\n placeholder={resolvedPlaceholder}\n className=\"min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground\"\n />\n </div>\n\n {showPicker || showCcBcc ? (\n <div className=\"flex gap-1.5 mt-2\">\n {showPicker ? (\n <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>\n <PopoverPrimitive.Trigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Users className=\"size-3\" />\n Contacts\n <ChevronDown className=\"size-3\" />\n </button>\n </PopoverPrimitive.Trigger>\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n side=\"bottom\"\n align=\"start\"\n sideOffset={4}\n collisionPadding={16}\n className=\"z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0\"\n >\n <ContactPickerContents\n contacts={contacts}\n addedEmails={added}\n onSelect={selectContact}\n onAddEmail={(email) => {\n addEmail(email)\n setPickerOpen(false)\n }}\n onSearch={onSearch}\n searchLoading={searchLoading}\n />\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n </PopoverPrimitive.Root>\n ) : null}\n {showCcBcc ? (\n <button\n type=\"button\"\n onClick={onCcBccToggle}\n className=\"inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]\"\n >\n <Plus className=\"size-3\" />\n {ccBccOpen ? \"Hide Cc/Bcc\" : \"Add Cc/Bcc\"}\n </button>\n ) : null}\n </div>\n ) : null}\n </div>\n </div>\n )\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest"
|
|
2
2
|
import React from "react"
|
|
3
|
-
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
|
|
3
|
+
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
EmailRecipientField,
|
|
7
7
|
type RecipientChip,
|
|
8
8
|
} from "../email-recipient-field"
|
|
9
|
+
import {
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "../dialog"
|
|
9
14
|
import type { SuggestedContact } from "../suggested-actions"
|
|
10
15
|
|
|
11
16
|
afterEach(() => {
|
|
@@ -53,6 +58,53 @@ describe("EmailRecipientField", () => {
|
|
|
53
58
|
])
|
|
54
59
|
})
|
|
55
60
|
|
|
61
|
+
it("adds an unconfirmed chip on Tab without trapping focus", () => {
|
|
62
|
+
const onChange = vi.fn()
|
|
63
|
+
render(
|
|
64
|
+
<EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const input = getInput()
|
|
68
|
+
fireEvent.change(input, { target: { value: "tab@example.com" } })
|
|
69
|
+
const wasNotCancelled = fireEvent.keyDown(input, { key: "Tab" })
|
|
70
|
+
|
|
71
|
+
expect(wasNotCancelled).toBe(true)
|
|
72
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
73
|
+
{ id: "tab@example.com", email: "tab@example.com", name: "", confirmed: false },
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("adds an unconfirmed chip on blur", () => {
|
|
78
|
+
const onChange = vi.fn()
|
|
79
|
+
render(
|
|
80
|
+
<EmailRecipientField label="To" recipients={[]} onRecipientsChange={onChange} />,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const input = getInput()
|
|
84
|
+
fireEvent.change(input, { target: { value: "blur@example.com" } })
|
|
85
|
+
fireEvent.blur(input)
|
|
86
|
+
|
|
87
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
88
|
+
{ id: "blur@example.com", email: "blur@example.com", name: "", confirmed: false },
|
|
89
|
+
])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("uses the roomy confirmation chip treatment for unconfirmed recipients", () => {
|
|
93
|
+
render(
|
|
94
|
+
<EmailRecipientField
|
|
95
|
+
label="To"
|
|
96
|
+
recipients={[{ id: "a@b.com", email: "a@b.com", name: "Ada Lovelace", confirmed: false }]}
|
|
97
|
+
onRecipientsChange={vi.fn()}
|
|
98
|
+
/>,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const confirmButton = screen.getByRole("button", { name: "Confirm" })
|
|
102
|
+
expect(confirmButton.className).toContain("bg-amber-200/60")
|
|
103
|
+
expect(confirmButton.className).toContain("px-2")
|
|
104
|
+
expect(screen.getByText("Ada Lovelace").className).toContain("font-medium")
|
|
105
|
+
expect(screen.getByText("a@b.com").className).toContain("text-amber-800/70")
|
|
106
|
+
})
|
|
107
|
+
|
|
56
108
|
it("does not add an invalid email", () => {
|
|
57
109
|
const onChange = vi.fn()
|
|
58
110
|
render(
|
|
@@ -230,71 +282,170 @@ describe("EmailRecipientField", () => {
|
|
|
230
282
|
])
|
|
231
283
|
})
|
|
232
284
|
|
|
233
|
-
it("
|
|
234
|
-
const onSearch = vi.fn()
|
|
235
|
-
// In async mode the parent owns filtering, so we pass an unrelated result
|
|
236
|
-
// set and assert it is rendered verbatim (not filtered by the typed query).
|
|
237
|
-
const serverResults: SuggestedContact[] = [
|
|
238
|
-
{ name: "Server Result", role: "VP", email: "server@example.com", confirmed: true },
|
|
239
|
-
]
|
|
285
|
+
it("disables rows for added and no-email contacts", () => {
|
|
240
286
|
render(
|
|
241
287
|
<EmailRecipientField
|
|
242
288
|
label="To"
|
|
243
289
|
recipients={[]}
|
|
244
290
|
onRecipientsChange={vi.fn()}
|
|
245
291
|
showPicker
|
|
246
|
-
contacts={
|
|
247
|
-
|
|
292
|
+
contacts={contacts}
|
|
293
|
+
addedEmails={new Set(["alex@example.com"])}
|
|
248
294
|
/>,
|
|
249
295
|
)
|
|
250
296
|
|
|
251
297
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
252
|
-
// Opening the picker forwards the initial (empty) query.
|
|
253
|
-
expect(onSearch).toHaveBeenCalledWith("")
|
|
254
298
|
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
299
|
+
const options = screen.getAllByRole("option")
|
|
300
|
+
const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
|
|
301
|
+
const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
|
|
258
302
|
|
|
259
|
-
|
|
260
|
-
expect(
|
|
303
|
+
expect(alexRow.className).toContain("pointer-events-none")
|
|
304
|
+
expect(alexRow.textContent).toContain("Added")
|
|
305
|
+
expect(noEmailRow.className).toContain("pointer-events-none")
|
|
306
|
+
expect(noEmailRow.textContent).toContain("No email")
|
|
261
307
|
})
|
|
262
308
|
|
|
263
|
-
it("
|
|
264
|
-
|
|
265
|
-
const secondOnSearch = vi.fn()
|
|
266
|
-
const { rerender } = render(
|
|
309
|
+
it("autofocuses the search input when the picker opens", () => {
|
|
310
|
+
render(
|
|
267
311
|
<EmailRecipientField
|
|
268
312
|
label="To"
|
|
269
313
|
recipients={[]}
|
|
270
314
|
onRecipientsChange={vi.fn()}
|
|
271
315
|
showPicker
|
|
272
|
-
contacts={
|
|
273
|
-
onSearch={firstOnSearch}
|
|
316
|
+
contacts={contacts}
|
|
274
317
|
/>,
|
|
275
318
|
)
|
|
276
319
|
|
|
277
320
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
278
|
-
|
|
279
|
-
expect(
|
|
280
|
-
|
|
321
|
+
const search = screen.getByPlaceholderText("Search contacts...")
|
|
322
|
+
expect(document.activeElement).toBe(search)
|
|
323
|
+
})
|
|
281
324
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
325
|
+
it("accepts typing in the search box and filters the list", () => {
|
|
326
|
+
render(
|
|
327
|
+
<EmailRecipientField
|
|
328
|
+
label="To"
|
|
329
|
+
recipients={[]}
|
|
330
|
+
onRecipientsChange={vi.fn()}
|
|
331
|
+
showPicker
|
|
332
|
+
contacts={contacts}
|
|
333
|
+
/>,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
337
|
+
const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
|
|
338
|
+
|
|
339
|
+
fireEvent.change(search, { target: { value: "Bea" } })
|
|
340
|
+
expect(search.value).toBe("Bea")
|
|
341
|
+
expect(screen.getByText("Bea Buyer")).toBeTruthy()
|
|
342
|
+
expect(screen.queryByText("Alex Admin")).toBeNull()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it("adds a typed email from the picker search box on Enter", () => {
|
|
346
|
+
const onChange = vi.fn()
|
|
347
|
+
render(
|
|
348
|
+
<EmailRecipientField
|
|
349
|
+
label="To"
|
|
350
|
+
recipients={[]}
|
|
351
|
+
onRecipientsChange={onChange}
|
|
352
|
+
showPicker
|
|
353
|
+
contacts={contacts}
|
|
354
|
+
/>,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
358
|
+
const search = screen.getByPlaceholderText("Search contacts...")
|
|
359
|
+
|
|
360
|
+
fireEvent.change(search, { target: { value: "typed@example.com" } })
|
|
361
|
+
fireEvent.keyDown(search, { key: "Enter" })
|
|
362
|
+
|
|
363
|
+
expect(onChange).toHaveBeenCalledWith([
|
|
364
|
+
{ id: "typed@example.com", email: "typed@example.com", name: "", confirmed: false },
|
|
365
|
+
])
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("shows a clear empty state when there are no contacts", () => {
|
|
369
|
+
render(
|
|
285
370
|
<EmailRecipientField
|
|
286
371
|
label="To"
|
|
287
372
|
recipients={[]}
|
|
288
373
|
onRecipientsChange={vi.fn()}
|
|
289
374
|
showPicker
|
|
290
375
|
contacts={[]}
|
|
291
|
-
onSearch={secondOnSearch}
|
|
292
376
|
/>,
|
|
293
377
|
)
|
|
294
|
-
|
|
378
|
+
|
|
379
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
380
|
+
expect(screen.getByText("No contacts for this account")).toBeTruthy()
|
|
381
|
+
// The empty state guides the user to type an address instead of looking broken.
|
|
382
|
+
expect(
|
|
383
|
+
screen.getByText(/Type an email address above and press Enter/),
|
|
384
|
+
).toBeTruthy()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it("remains typeable when rendered inside a modal Dialog (WIT-800 focus trap)", () => {
|
|
388
|
+
// Reproduces the staging bug: the picker used to render via
|
|
389
|
+
// createPortal(document.body), outside the Dialog's FocusScope, so the
|
|
390
|
+
// search input could not hold focus. The Radix Popover rebuild keeps the
|
|
391
|
+
// input inside its own (parent-pausing) focus scope.
|
|
392
|
+
render(
|
|
393
|
+
<Dialog open>
|
|
394
|
+
<DialogContent aria-describedby={undefined}>
|
|
395
|
+
<DialogTitle>Compose</DialogTitle>
|
|
396
|
+
<EmailRecipientField
|
|
397
|
+
label="To"
|
|
398
|
+
recipients={[]}
|
|
399
|
+
onRecipientsChange={vi.fn()}
|
|
400
|
+
showPicker
|
|
401
|
+
contacts={contacts}
|
|
402
|
+
/>
|
|
403
|
+
</DialogContent>
|
|
404
|
+
</Dialog>,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
408
|
+
const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
|
|
409
|
+
|
|
410
|
+
search.focus()
|
|
411
|
+
expect(document.activeElement).toBe(search)
|
|
412
|
+
|
|
413
|
+
fireEvent.change(search, { target: { value: "Alex" } })
|
|
414
|
+
expect(search.value).toBe("Alex")
|
|
415
|
+
const listbox = screen.getByRole("listbox")
|
|
416
|
+
expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it("async mode: forwards the query to onSearch and skips client-side filtering", () => {
|
|
420
|
+
const onSearch = vi.fn()
|
|
421
|
+
// In async mode `contacts` is the already server-filtered result set, so
|
|
422
|
+
// every supplied contact should render regardless of the typed query.
|
|
423
|
+
render(
|
|
424
|
+
<EmailRecipientField
|
|
425
|
+
label="To"
|
|
426
|
+
recipients={[]}
|
|
427
|
+
onRecipientsChange={vi.fn()}
|
|
428
|
+
showPicker
|
|
429
|
+
contacts={contacts}
|
|
430
|
+
onSearch={onSearch}
|
|
431
|
+
/>,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
435
|
+
// Opening forwards the initial empty query.
|
|
436
|
+
expect(onSearch).toHaveBeenCalledWith("")
|
|
437
|
+
|
|
438
|
+
const search = screen.getByPlaceholderText("Search contacts...")
|
|
439
|
+
fireEvent.change(search, { target: { value: "zzz" } })
|
|
440
|
+
|
|
441
|
+
expect(onSearch).toHaveBeenCalledWith("zzz")
|
|
442
|
+
const listbox = screen.getByRole("listbox")
|
|
443
|
+
// No client-side filtering: server-provided contacts still show.
|
|
444
|
+
expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
|
|
445
|
+
expect(within(listbox).getByText("Bea Buyer")).toBeTruthy()
|
|
295
446
|
})
|
|
296
447
|
|
|
297
|
-
it("async mode: shows
|
|
448
|
+
it("async mode: shows a searching indicator while results load", () => {
|
|
298
449
|
render(
|
|
299
450
|
<EmailRecipientField
|
|
300
451
|
label="To"
|
|
@@ -311,7 +462,7 @@ describe("EmailRecipientField", () => {
|
|
|
311
462
|
expect(screen.getByText("Searching contacts...")).toBeTruthy()
|
|
312
463
|
})
|
|
313
464
|
|
|
314
|
-
it("async mode: prompts to type before searching when query is empty", () => {
|
|
465
|
+
it("async mode: prompts to type before searching when the query is empty", () => {
|
|
315
466
|
render(
|
|
316
467
|
<EmailRecipientField
|
|
317
468
|
label="To"
|
|
@@ -324,30 +475,42 @@ describe("EmailRecipientField", () => {
|
|
|
324
475
|
)
|
|
325
476
|
|
|
326
477
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
327
|
-
expect(
|
|
478
|
+
expect(
|
|
479
|
+
screen.getByText("Type a name or email to search contacts."),
|
|
480
|
+
).toBeTruthy()
|
|
328
481
|
})
|
|
329
482
|
|
|
330
|
-
it("
|
|
331
|
-
|
|
483
|
+
it("async mode: does not re-search the same query when only onSearch identity changes", () => {
|
|
484
|
+
const firstOnSearch = vi.fn()
|
|
485
|
+
const { rerender } = render(
|
|
332
486
|
<EmailRecipientField
|
|
333
487
|
label="To"
|
|
334
488
|
recipients={[]}
|
|
335
489
|
onRecipientsChange={vi.fn()}
|
|
336
490
|
showPicker
|
|
337
|
-
contacts={
|
|
338
|
-
|
|
491
|
+
contacts={[]}
|
|
492
|
+
onSearch={firstOnSearch}
|
|
339
493
|
/>,
|
|
340
494
|
)
|
|
341
495
|
|
|
342
496
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
497
|
+
expect(firstOnSearch).toHaveBeenCalledTimes(1)
|
|
498
|
+
expect(firstOnSearch).toHaveBeenCalledWith("")
|
|
343
499
|
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
500
|
+
const secondOnSearch = vi.fn()
|
|
501
|
+
rerender(
|
|
502
|
+
<EmailRecipientField
|
|
503
|
+
label="To"
|
|
504
|
+
recipients={[]}
|
|
505
|
+
onRecipientsChange={vi.fn()}
|
|
506
|
+
showPicker
|
|
507
|
+
contacts={[]}
|
|
508
|
+
onSearch={secondOnSearch}
|
|
509
|
+
/>,
|
|
510
|
+
)
|
|
347
511
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
expect(
|
|
351
|
-
expect(noEmailRow.textContent).toContain("No email")
|
|
512
|
+
// Query is unchanged (""), so swapping the callback identity must not
|
|
513
|
+
// trigger another search.
|
|
514
|
+
expect(secondOnSearch).not.toHaveBeenCalled()
|
|
352
515
|
})
|
|
353
516
|
})
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import {
|
|
4
|
+
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
5
5
|
import {
|
|
6
|
-
Check,
|
|
7
6
|
ChevronDown,
|
|
8
7
|
CornerDownLeft,
|
|
9
8
|
Plus,
|
|
@@ -57,14 +56,12 @@ export interface EmailRecipientFieldProps {
|
|
|
57
56
|
placeholder?: string
|
|
58
57
|
contactToRecipient?: (contact: SuggestedContact) => RecipientChip
|
|
59
58
|
/**
|
|
60
|
-
* Async search
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* result set rather than a static list to filter client-side. When omitted,
|
|
64
|
-
* the picker filters the static `contacts` array locally (default behavior).
|
|
59
|
+
* Async search mode. When provided, the picker forwards the typed query to
|
|
60
|
+
* this callback (the caller debounces + fetches) and treats `contacts` as the
|
|
61
|
+
* already server-filtered result set instead of filtering it client-side.
|
|
65
62
|
*/
|
|
66
63
|
onSearch?: (query: string) => void
|
|
67
|
-
/** Shows a
|
|
64
|
+
/** Shows a "Searching contacts..." indicator while async results load. */
|
|
68
65
|
searchLoading?: boolean
|
|
69
66
|
}
|
|
70
67
|
|
|
@@ -77,16 +74,25 @@ function RecipientChipPill({
|
|
|
77
74
|
onConfirm: () => void
|
|
78
75
|
onRemove: () => void
|
|
79
76
|
}) {
|
|
80
|
-
const
|
|
77
|
+
const primary = recipient.name || recipient.email
|
|
78
|
+
const secondary = recipient.name ? recipient.email : ""
|
|
79
|
+
const display = primary || recipient.email
|
|
81
80
|
|
|
82
81
|
if (!recipient.confirmed) {
|
|
83
82
|
return (
|
|
84
|
-
<span className="inline-flex items-center gap-1
|
|
85
|
-
<span className="
|
|
83
|
+
<span className="inline-flex max-w-full items-center gap-1 rounded text-xs border border-amber-300 bg-amber-50 px-2 py-0.5 text-amber-800">
|
|
84
|
+
<span className="min-w-0 inline-flex items-baseline gap-1">
|
|
85
|
+
<span className="truncate font-medium max-w-[150px]">{primary}</span>
|
|
86
|
+
{secondary ? (
|
|
87
|
+
<span className="truncate max-w-[190px] text-amber-800/70">
|
|
88
|
+
{secondary}
|
|
89
|
+
</span>
|
|
90
|
+
) : null}
|
|
91
|
+
</span>
|
|
86
92
|
<button
|
|
87
93
|
type="button"
|
|
88
94
|
onClick={onConfirm}
|
|
89
|
-
className="
|
|
95
|
+
className="ml-1 rounded bg-amber-200/60 px-2 py-0.5 text-xs font-semibold text-amber-800 hover:bg-amber-200"
|
|
90
96
|
>
|
|
91
97
|
Confirm
|
|
92
98
|
</button>
|
|
@@ -94,7 +100,7 @@ function RecipientChipPill({
|
|
|
94
100
|
type="button"
|
|
95
101
|
aria-label={`Remove ${display}`}
|
|
96
102
|
onClick={onRemove}
|
|
97
|
-
className="inline-flex items-center justify-center
|
|
103
|
+
className="inline-flex size-4 items-center justify-center rounded text-amber-700/80 hover:bg-amber-200/70"
|
|
98
104
|
>
|
|
99
105
|
<X className="size-3" />
|
|
100
106
|
</button>
|
|
@@ -103,16 +109,20 @@ function RecipientChipPill({
|
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
return (
|
|
106
|
-
<span className="inline-flex items-center gap-1
|
|
107
|
-
<span className="inline-flex items-
|
|
108
|
-
<
|
|
112
|
+
<span className="inline-flex max-w-full items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-xs text-foreground">
|
|
113
|
+
<span className="min-w-0 inline-flex items-baseline gap-1">
|
|
114
|
+
<span className="truncate font-medium max-w-[150px]">{primary}</span>
|
|
115
|
+
{secondary ? (
|
|
116
|
+
<span className="truncate max-w-[190px] text-muted-foreground">
|
|
117
|
+
{secondary}
|
|
118
|
+
</span>
|
|
119
|
+
) : null}
|
|
109
120
|
</span>
|
|
110
|
-
<span className="truncate max-w-[180px]">{display}</span>
|
|
111
121
|
<button
|
|
112
122
|
type="button"
|
|
113
123
|
aria-label={`Remove ${display}`}
|
|
114
124
|
onClick={onRemove}
|
|
115
|
-
className="inline-flex items-center justify-center
|
|
125
|
+
className="inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
116
126
|
>
|
|
117
127
|
<X className="size-3" />
|
|
118
128
|
</button>
|
|
@@ -120,86 +130,31 @@ function RecipientChipPill({
|
|
|
120
130
|
)
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
// Contents of the contact picker dropdown. Rendered inside a Radix
|
|
134
|
+
// `Popover.Content` so its focus scope pushes onto the focus-scope stack and
|
|
135
|
+
// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what
|
|
136
|
+
// makes the search input typeable: a plain `createPortal(..., document.body)`
|
|
137
|
+
// element renders outside the Dialog's `DialogContent`, so the Dialog's
|
|
138
|
+
// FocusScope kept yanking focus back (input un-typeable) and its modal
|
|
139
|
+
// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix
|
|
140
|
+
// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus
|
|
141
|
+
// scope, fixing both. See WIT-800 / WIT-770.
|
|
142
|
+
function ContactPickerContents({
|
|
125
143
|
contacts,
|
|
126
144
|
addedEmails,
|
|
127
145
|
onSelect,
|
|
128
146
|
onAddEmail,
|
|
129
|
-
onClose,
|
|
130
147
|
onSearch,
|
|
131
148
|
searchLoading = false,
|
|
132
149
|
}: {
|
|
133
|
-
triggerRef: React.RefObject<HTMLElement | null>
|
|
134
150
|
contacts: SuggestedContact[]
|
|
135
151
|
addedEmails: Set<string>
|
|
136
152
|
onSelect: (contact: SuggestedContact) => void
|
|
137
153
|
onAddEmail: (email: string) => void
|
|
138
|
-
onClose: () => void
|
|
139
154
|
onSearch?: (query: string) => void
|
|
140
155
|
searchLoading?: boolean
|
|
141
156
|
}) {
|
|
142
|
-
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
143
|
-
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
144
157
|
const [query, setQuery] = React.useState("")
|
|
145
|
-
const [style, setStyle] = React.useState<React.CSSProperties>({
|
|
146
|
-
position: "fixed",
|
|
147
|
-
top: -9999,
|
|
148
|
-
left: -9999,
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
React.useEffect(() => {
|
|
152
|
-
const trigger = triggerRef.current
|
|
153
|
-
if (!trigger) return
|
|
154
|
-
const rect = trigger.getBoundingClientRect()
|
|
155
|
-
const width = Math.min(448, window.innerWidth - 32)
|
|
156
|
-
let left = rect.left
|
|
157
|
-
if (left + width > window.innerWidth - 16) {
|
|
158
|
-
left = window.innerWidth - 16 - width
|
|
159
|
-
}
|
|
160
|
-
if (left < 16) left = 16
|
|
161
|
-
const popoverHeight = 280
|
|
162
|
-
const spaceBelow = window.innerHeight - rect.bottom - 4
|
|
163
|
-
const spaceAbove = rect.top - 4
|
|
164
|
-
const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow
|
|
165
|
-
let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4
|
|
166
|
-
if (top < 16) top = 16
|
|
167
|
-
setStyle({ position: "fixed", top, left, width })
|
|
168
|
-
}, [triggerRef])
|
|
169
|
-
|
|
170
|
-
React.useEffect(() => {
|
|
171
|
-
searchRef.current?.focus()
|
|
172
|
-
const trigger = triggerRef.current
|
|
173
|
-
return () => {
|
|
174
|
-
if (trigger && typeof trigger.focus === "function") {
|
|
175
|
-
trigger.focus()
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}, [triggerRef])
|
|
179
|
-
|
|
180
|
-
React.useEffect(() => {
|
|
181
|
-
function handleMouseDown(event: MouseEvent) {
|
|
182
|
-
if (
|
|
183
|
-
containerRef.current &&
|
|
184
|
-
!containerRef.current.contains(event.target as Node) &&
|
|
185
|
-
!triggerRef.current?.contains(event.target as Node)
|
|
186
|
-
) {
|
|
187
|
-
onClose()
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
function handleKeyDown(event: KeyboardEvent) {
|
|
191
|
-
if (event.key === "Escape") {
|
|
192
|
-
event.stopPropagation()
|
|
193
|
-
onClose()
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
document.addEventListener("mousedown", handleMouseDown)
|
|
197
|
-
document.addEventListener("keydown", handleKeyDown)
|
|
198
|
-
return () => {
|
|
199
|
-
document.removeEventListener("mousedown", handleMouseDown)
|
|
200
|
-
document.removeEventListener("keydown", handleKeyDown)
|
|
201
|
-
}
|
|
202
|
-
}, [onClose, triggerRef])
|
|
203
158
|
|
|
204
159
|
const asyncMode = typeof onSearch === "function"
|
|
205
160
|
|
|
@@ -245,16 +200,11 @@ function ContactPickerPopover({
|
|
|
245
200
|
}
|
|
246
201
|
}
|
|
247
202
|
|
|
248
|
-
return
|
|
249
|
-
|
|
250
|
-
ref={containerRef}
|
|
251
|
-
style={style}
|
|
252
|
-
className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
|
|
253
|
-
>
|
|
203
|
+
return (
|
|
204
|
+
<>
|
|
254
205
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
|
|
255
206
|
<Search className="size-4 text-muted-foreground shrink-0" />
|
|
256
207
|
<input
|
|
257
|
-
ref={searchRef}
|
|
258
208
|
autoFocus
|
|
259
209
|
value={query}
|
|
260
210
|
onChange={(event) => setQuery(event.target.value)}
|
|
@@ -266,16 +216,25 @@ function ContactPickerPopover({
|
|
|
266
216
|
|
|
267
217
|
<div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
|
|
268
218
|
{searchLoading ? (
|
|
269
|
-
<div className="px-3 py-
|
|
219
|
+
<div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
|
|
270
220
|
Searching contacts...
|
|
271
221
|
</div>
|
|
222
|
+
) : asyncMode && normalizedQuery.length === 0 ? (
|
|
223
|
+
<div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
|
|
224
|
+
Type a name or email to search contacts.
|
|
225
|
+
</div>
|
|
226
|
+
) : !asyncMode && contacts.length === 0 ? (
|
|
227
|
+
<div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
|
|
228
|
+
<div className="font-medium text-foreground/80">
|
|
229
|
+
No contacts for this account
|
|
230
|
+
</div>
|
|
231
|
+
<div className="mt-1">
|
|
232
|
+
Type an email address above and press Enter to add a recipient.
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
272
235
|
) : filtered.length === 0 ? (
|
|
273
236
|
<div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
|
274
|
-
|
|
275
|
-
<div>Type a name or email to search contacts.</div>
|
|
276
|
-
) : (
|
|
277
|
-
<div>No contact matches ‘{query}’.</div>
|
|
278
|
-
)}
|
|
237
|
+
<div>No contact matches ‘{query}’.</div>
|
|
279
238
|
{queryIsEmail ? (
|
|
280
239
|
<div className="mt-1">Press Enter to add {query}.</div>
|
|
281
240
|
) : null}
|
|
@@ -340,8 +299,7 @@ function ContactPickerPopover({
|
|
|
340
299
|
<CornerDownLeft className="size-3 shrink-0" />
|
|
341
300
|
<span>Type an address and press Enter to add someone not listed.</span>
|
|
342
301
|
</div>
|
|
343
|
-
|
|
344
|
-
document.body,
|
|
302
|
+
</>
|
|
345
303
|
)
|
|
346
304
|
}
|
|
347
305
|
|
|
@@ -363,7 +321,6 @@ export function EmailRecipientField({
|
|
|
363
321
|
}: EmailRecipientFieldProps) {
|
|
364
322
|
const [value, setValue] = React.useState("")
|
|
365
323
|
const [pickerOpen, setPickerOpen] = React.useState(false)
|
|
366
|
-
const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
|
|
367
324
|
|
|
368
325
|
const hasUnconfirmed = recipients.some((r) => !r.confirmed)
|
|
369
326
|
const state: "default" | "amber" =
|
|
@@ -375,23 +332,33 @@ export function EmailRecipientField({
|
|
|
375
332
|
const resolvedPlaceholder =
|
|
376
333
|
placeholder ?? (recipients.length > 0 ? "Add another..." : "Add email...")
|
|
377
334
|
|
|
378
|
-
|
|
335
|
+
const committedByKeyRef = React.useRef<string | null>(null)
|
|
336
|
+
|
|
337
|
+
function addEmail(email: string): boolean {
|
|
379
338
|
const trimmed = email.trim()
|
|
380
|
-
if (!isValidEmail(trimmed)) return
|
|
381
|
-
if (added.has(trimmed.toLowerCase())) return
|
|
339
|
+
if (!isValidEmail(trimmed)) return false
|
|
340
|
+
if (added.has(trimmed.toLowerCase())) return false
|
|
382
341
|
onRecipientsChange([
|
|
383
342
|
...recipients,
|
|
384
343
|
{ id: trimmed, email: trimmed, name: "", confirmed: false },
|
|
385
344
|
])
|
|
386
345
|
setValue("")
|
|
346
|
+
return true
|
|
387
347
|
}
|
|
388
348
|
|
|
389
349
|
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
390
|
-
if ((event.key === "Enter" || event.key === ",") &&
|
|
350
|
+
if ((event.key === "Enter" || event.key === ",") && value.trim()) {
|
|
391
351
|
event.preventDefault()
|
|
392
352
|
addEmail(value)
|
|
393
353
|
return
|
|
394
354
|
}
|
|
355
|
+
if (event.key === "Tab" && value.trim()) {
|
|
356
|
+
const trimmed = value.trim()
|
|
357
|
+
if (addEmail(trimmed)) {
|
|
358
|
+
committedByKeyRef.current = trimmed.toLowerCase()
|
|
359
|
+
}
|
|
360
|
+
return
|
|
361
|
+
}
|
|
395
362
|
if (event.key === "Backspace" && value === "" && recipients.length > 0) {
|
|
396
363
|
event.preventDefault()
|
|
397
364
|
onRecipientsChange(recipients.slice(0, -1))
|
|
@@ -452,9 +419,14 @@ export function EmailRecipientField({
|
|
|
452
419
|
onChange={(event) => setValue(event.target.value)}
|
|
453
420
|
onKeyDown={handleKeyDown}
|
|
454
421
|
onBlur={() => {
|
|
422
|
+
const trimmed = value.trim().toLowerCase()
|
|
423
|
+
if (trimmed && committedByKeyRef.current === trimmed) {
|
|
424
|
+
committedByKeyRef.current = null
|
|
425
|
+
return
|
|
426
|
+
}
|
|
455
427
|
// Commit any valid pending email so it is not silently dropped
|
|
456
428
|
// when the user clicks Send without pressing Enter/comma first.
|
|
457
|
-
|
|
429
|
+
addEmail(value)
|
|
458
430
|
}}
|
|
459
431
|
placeholder={resolvedPlaceholder}
|
|
460
432
|
className="min-w-[130px] flex-1 h-6 text-[13px] bg-transparent border-0 outline-none placeholder:text-muted-foreground"
|
|
@@ -464,16 +436,39 @@ export function EmailRecipientField({
|
|
|
464
436
|
{showPicker || showCcBcc ? (
|
|
465
437
|
<div className="flex gap-1.5 mt-2">
|
|
466
438
|
{showPicker ? (
|
|
467
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
439
|
+
<PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
440
|
+
<PopoverPrimitive.Trigger asChild>
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
|
|
444
|
+
>
|
|
445
|
+
<Users className="size-3" />
|
|
446
|
+
Contacts
|
|
447
|
+
<ChevronDown className="size-3" />
|
|
448
|
+
</button>
|
|
449
|
+
</PopoverPrimitive.Trigger>
|
|
450
|
+
<PopoverPrimitive.Portal>
|
|
451
|
+
<PopoverPrimitive.Content
|
|
452
|
+
side="bottom"
|
|
453
|
+
align="start"
|
|
454
|
+
sideOffset={4}
|
|
455
|
+
collisionPadding={16}
|
|
456
|
+
className="z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0"
|
|
457
|
+
>
|
|
458
|
+
<ContactPickerContents
|
|
459
|
+
contacts={contacts}
|
|
460
|
+
addedEmails={added}
|
|
461
|
+
onSelect={selectContact}
|
|
462
|
+
onAddEmail={(email) => {
|
|
463
|
+
addEmail(email)
|
|
464
|
+
setPickerOpen(false)
|
|
465
|
+
}}
|
|
466
|
+
onSearch={onSearch}
|
|
467
|
+
searchLoading={searchLoading}
|
|
468
|
+
/>
|
|
469
|
+
</PopoverPrimitive.Content>
|
|
470
|
+
</PopoverPrimitive.Portal>
|
|
471
|
+
</PopoverPrimitive.Root>
|
|
477
472
|
) : null}
|
|
478
473
|
{showCcBcc ? (
|
|
479
474
|
<button
|
|
@@ -487,22 +482,6 @@ export function EmailRecipientField({
|
|
|
487
482
|
) : null}
|
|
488
483
|
</div>
|
|
489
484
|
) : null}
|
|
490
|
-
|
|
491
|
-
{pickerOpen ? (
|
|
492
|
-
<ContactPickerPopover
|
|
493
|
-
triggerRef={contactsTriggerRef}
|
|
494
|
-
contacts={contacts}
|
|
495
|
-
addedEmails={added}
|
|
496
|
-
onSelect={selectContact}
|
|
497
|
-
onAddEmail={(email) => {
|
|
498
|
-
addEmail(email)
|
|
499
|
-
setPickerOpen(false)
|
|
500
|
-
}}
|
|
501
|
-
onClose={() => setPickerOpen(false)}
|
|
502
|
-
onSearch={onSearch}
|
|
503
|
-
searchLoading={searchLoading}
|
|
504
|
-
/>
|
|
505
|
-
) : null}
|
|
506
485
|
</div>
|
|
507
486
|
</div>
|
|
508
487
|
)
|