@handled-ai/design-system 0.18.40 → 0.18.42
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 +11 -1
- package/dist/components/email-recipient-field.js +185 -109
- package/dist/components/email-recipient-field.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/email-recipient-field.test.tsx +57 -75
- package/src/components/email-recipient-field.tsx +158 -64
|
@@ -20,7 +20,17 @@ interface EmailRecipientFieldProps {
|
|
|
20
20
|
addedEmails?: Set<string>;
|
|
21
21
|
placeholder?: string;
|
|
22
22
|
contactToRecipient?: (contact: SuggestedContact) => RecipientChip;
|
|
23
|
+
/**
|
|
24
|
+
* Async search hook. When provided, the contact picker switches to async
|
|
25
|
+
* mode: the search box forwards its query here (the caller is responsible for
|
|
26
|
+
* debouncing and fetching) and `contacts` is treated as the server-filtered
|
|
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).
|
|
29
|
+
*/
|
|
30
|
+
onSearch?: (query: string) => void;
|
|
31
|
+
/** Shows a loading indicator in the picker while async results are fetched. */
|
|
32
|
+
searchLoading?: boolean;
|
|
23
33
|
}
|
|
24
|
-
declare function EmailRecipientField({ label, recipients, onRecipientsChange, amber, contacts, showPicker, showCcBcc, ccBccOpen, onCcBccToggle, addedEmails, placeholder, contactToRecipient, }: EmailRecipientFieldProps): React.JSX.Element;
|
|
34
|
+
declare function EmailRecipientField({ label, recipients, onRecipientsChange, amber, contacts, showPicker, showCcBcc, ccBccOpen, onCcBccToggle, addedEmails, placeholder, contactToRecipient, onSearch, searchLoading, }: EmailRecipientFieldProps): React.JSX.Element;
|
|
25
35
|
|
|
26
36
|
export { EmailRecipientField, type EmailRecipientFieldProps, type RecipientChip };
|
|
@@ -20,9 +20,9 @@ var __spreadValues = (a, b) => {
|
|
|
20
20
|
return a;
|
|
21
21
|
};
|
|
22
22
|
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
|
|
23
|
-
import {
|
|
23
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
24
24
|
import * as React from "react";
|
|
25
|
-
import {
|
|
25
|
+
import { createPortal } from "react-dom";
|
|
26
26
|
import {
|
|
27
27
|
Check,
|
|
28
28
|
ChevronDown,
|
|
@@ -90,15 +90,85 @@ function RecipientChipPill({
|
|
|
90
90
|
)
|
|
91
91
|
] });
|
|
92
92
|
}
|
|
93
|
-
function
|
|
93
|
+
function ContactPickerPopover({
|
|
94
|
+
triggerRef,
|
|
94
95
|
contacts,
|
|
95
96
|
addedEmails,
|
|
96
97
|
onSelect,
|
|
97
|
-
onAddEmail
|
|
98
|
+
onAddEmail,
|
|
99
|
+
onClose,
|
|
100
|
+
onSearch,
|
|
101
|
+
searchLoading = false
|
|
98
102
|
}) {
|
|
103
|
+
const containerRef = React.useRef(null);
|
|
104
|
+
const searchRef = React.useRef(null);
|
|
99
105
|
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
|
+
const asyncMode = typeof onSearch === "function";
|
|
160
|
+
const onSearchRef = React.useRef(onSearch);
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
onSearchRef.current = onSearch;
|
|
163
|
+
}, [onSearch]);
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
var _a;
|
|
166
|
+
if (asyncMode) {
|
|
167
|
+
(_a = onSearchRef.current) == null ? void 0 : _a.call(onSearchRef, query);
|
|
168
|
+
}
|
|
169
|
+
}, [asyncMode, query]);
|
|
100
170
|
const normalizedQuery = query.trim().toLowerCase();
|
|
101
|
-
const filtered = normalizedQuery ? contacts.filter((contact) => {
|
|
171
|
+
const filtered = asyncMode ? contacts : normalizedQuery ? contacts.filter((contact) => {
|
|
102
172
|
var _a;
|
|
103
173
|
const email = (_a = contactEmail(contact)) != null ? _a : "";
|
|
104
174
|
return contact.name.toLowerCase().includes(normalizedQuery) || contact.role.toLowerCase().includes(normalizedQuery) || email.toLowerCase().includes(normalizedQuery);
|
|
@@ -111,73 +181,82 @@ function ContactPickerContents({
|
|
|
111
181
|
setQuery("");
|
|
112
182
|
}
|
|
113
183
|
}
|
|
114
|
-
return
|
|
115
|
-
/* @__PURE__ */ jsxs(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
] }) : null
|
|
143
|
-
] }) : filtered.map((contact, index) => {
|
|
144
|
-
const email = contactEmail(contact);
|
|
145
|
-
const noEmail = !email || !isValidEmail(email);
|
|
146
|
-
const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
|
|
147
|
-
const disabled = noEmail || alreadyAdded;
|
|
148
|
-
return /* @__PURE__ */ jsxs(
|
|
149
|
-
"div",
|
|
150
|
-
{
|
|
151
|
-
role: "option",
|
|
152
|
-
"aria-selected": false,
|
|
153
|
-
"aria-disabled": disabled,
|
|
154
|
-
onClick: () => {
|
|
155
|
-
if (!disabled) onSelect(contact);
|
|
156
|
-
},
|
|
157
|
-
className: cn(
|
|
158
|
-
"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
|
|
159
|
-
disabled && "opacity-45 pointer-events-none"
|
|
160
|
-
),
|
|
161
|
-
children: [
|
|
162
|
-
/* @__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 : "?") }),
|
|
163
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
164
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
|
|
165
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
|
|
166
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
|
|
167
|
-
] }),
|
|
168
|
-
email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
|
|
184
|
+
return createPortal(
|
|
185
|
+
/* @__PURE__ */ jsxs(
|
|
186
|
+
"div",
|
|
187
|
+
{
|
|
188
|
+
ref: containerRef,
|
|
189
|
+
style,
|
|
190
|
+
className: "bg-background border rounded-lg shadow-xl z-50 pointer-events-auto",
|
|
191
|
+
children: [
|
|
192
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
|
|
193
|
+
/* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
|
|
194
|
+
/* @__PURE__ */ jsx(
|
|
195
|
+
"input",
|
|
196
|
+
{
|
|
197
|
+
ref: searchRef,
|
|
198
|
+
autoFocus: true,
|
|
199
|
+
value: query,
|
|
200
|
+
onChange: (event) => setQuery(event.target.value),
|
|
201
|
+
onKeyDown: handleKeyDown,
|
|
202
|
+
className: "flex-1 text-[13px] bg-transparent outline-none",
|
|
203
|
+
placeholder: "Search contacts..."
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
] }),
|
|
207
|
+
/* @__PURE__ */ jsx("div", { role: "listbox", className: "max-h-[208px] overflow-y-auto p-1", children: searchLoading ? /* @__PURE__ */ jsx("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: "Searching contacts..." }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: [
|
|
208
|
+
asyncMode && normalizedQuery.length === 0 ? /* @__PURE__ */ jsx("div", { children: "Type a name or email to search contacts." }) : /* @__PURE__ */ jsxs("div", { children: [
|
|
209
|
+
"No contact matches \u2018",
|
|
210
|
+
query,
|
|
211
|
+
"\u2019."
|
|
169
212
|
] }),
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
213
|
+
queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
|
|
214
|
+
"Press Enter to add ",
|
|
215
|
+
query,
|
|
216
|
+
"."
|
|
217
|
+
] }) : null
|
|
218
|
+
] }) : filtered.map((contact, index) => {
|
|
219
|
+
const email = contactEmail(contact);
|
|
220
|
+
const noEmail = !email || !isValidEmail(email);
|
|
221
|
+
const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
|
|
222
|
+
const disabled = noEmail || alreadyAdded;
|
|
223
|
+
return /* @__PURE__ */ jsxs(
|
|
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
|
+
);
|
|
181
260
|
}
|
|
182
261
|
function EmailRecipientField({
|
|
183
262
|
label,
|
|
@@ -191,10 +270,13 @@ function EmailRecipientField({
|
|
|
191
270
|
onCcBccToggle,
|
|
192
271
|
addedEmails,
|
|
193
272
|
placeholder,
|
|
194
|
-
contactToRecipient
|
|
273
|
+
contactToRecipient,
|
|
274
|
+
onSearch,
|
|
275
|
+
searchLoading
|
|
195
276
|
}) {
|
|
196
277
|
const [value, setValue] = React.useState("");
|
|
197
278
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
279
|
+
const contactsTriggerRef = React.useRef(null);
|
|
198
280
|
const hasUnconfirmed = recipients.some((r) => !r.confirmed);
|
|
199
281
|
const state = amber && hasUnconfirmed ? "amber" : "default";
|
|
200
282
|
const amberRow = state === "amber";
|
|
@@ -284,42 +366,20 @@ function EmailRecipientField({
|
|
|
284
366
|
)
|
|
285
367
|
] }),
|
|
286
368
|
showPicker || showCcBcc ? /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 mt-2", children: [
|
|
287
|
-
showPicker ? /* @__PURE__ */ jsxs(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
PopoverPrimitive.Content,
|
|
302
|
-
{
|
|
303
|
-
side: "bottom",
|
|
304
|
-
align: "start",
|
|
305
|
-
sideOffset: 4,
|
|
306
|
-
collisionPadding: 16,
|
|
307
|
-
className: "z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0",
|
|
308
|
-
children: /* @__PURE__ */ jsx(
|
|
309
|
-
ContactPickerContents,
|
|
310
|
-
{
|
|
311
|
-
contacts,
|
|
312
|
-
addedEmails: added,
|
|
313
|
-
onSelect: selectContact,
|
|
314
|
-
onAddEmail: (email) => {
|
|
315
|
-
addEmail(email);
|
|
316
|
-
setPickerOpen(false);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
)
|
|
320
|
-
}
|
|
321
|
-
) })
|
|
322
|
-
] }) : null,
|
|
369
|
+
showPicker ? /* @__PURE__ */ jsxs(
|
|
370
|
+
"button",
|
|
371
|
+
{
|
|
372
|
+
ref: contactsTriggerRef,
|
|
373
|
+
type: "button",
|
|
374
|
+
onClick: () => setPickerOpen((open) => !open),
|
|
375
|
+
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]",
|
|
376
|
+
children: [
|
|
377
|
+
/* @__PURE__ */ jsx(Users, { className: "size-3" }),
|
|
378
|
+
"Contacts",
|
|
379
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
|
|
380
|
+
]
|
|
381
|
+
}
|
|
382
|
+
) : null,
|
|
323
383
|
showCcBcc ? /* @__PURE__ */ jsxs(
|
|
324
384
|
"button",
|
|
325
385
|
{
|
|
@@ -332,7 +392,23 @@ function EmailRecipientField({
|
|
|
332
392
|
]
|
|
333
393
|
}
|
|
334
394
|
) : null
|
|
335
|
-
] }) : 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
|
|
336
412
|
] })
|
|
337
413
|
]
|
|
338
414
|
}
|
|
@@ -1 +1 @@
|
|
|
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 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\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\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}: {\n contacts: SuggestedContact[]\n addedEmails: Set<string>\n onSelect: (contact: SuggestedContact) => void\n onAddEmail: (email: string) => void\n}) {\n const [query, setQuery] = React.useState(\"\")\n\n const normalizedQuery = query.trim().toLowerCase()\n const filtered = 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 {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}: 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 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 <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 />\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":[]}
|
|
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"]}
|
package/package.json
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from "vitest"
|
|
2
2
|
import React from "react"
|
|
3
|
-
import { render, screen, fireEvent, cleanup
|
|
3
|
+
import { render, screen, fireEvent, cleanup } 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"
|
|
14
9
|
import type { SuggestedContact } from "../suggested-actions"
|
|
15
10
|
|
|
16
11
|
afterEach(() => {
|
|
@@ -235,90 +230,88 @@ describe("EmailRecipientField", () => {
|
|
|
235
230
|
])
|
|
236
231
|
})
|
|
237
232
|
|
|
238
|
-
it("
|
|
233
|
+
it("async mode: forwards the query to onSearch and skips client-side filtering", () => {
|
|
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
|
+
]
|
|
239
240
|
render(
|
|
240
241
|
<EmailRecipientField
|
|
241
242
|
label="To"
|
|
242
243
|
recipients={[]}
|
|
243
244
|
onRecipientsChange={vi.fn()}
|
|
244
245
|
showPicker
|
|
245
|
-
contacts={
|
|
246
|
-
|
|
246
|
+
contacts={serverResults}
|
|
247
|
+
onSearch={onSearch}
|
|
247
248
|
/>,
|
|
248
249
|
)
|
|
249
250
|
|
|
250
251
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
252
|
+
// Opening the picker forwards the initial (empty) query.
|
|
253
|
+
expect(onSearch).toHaveBeenCalledWith("")
|
|
251
254
|
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
+
const searchInput = screen.getByPlaceholderText("Search contacts...")
|
|
256
|
+
fireEvent.change(searchInput, { target: { value: "zzz no local match" } })
|
|
257
|
+
expect(onSearch).toHaveBeenCalledWith("zzz no local match")
|
|
255
258
|
|
|
256
|
-
|
|
257
|
-
expect(
|
|
258
|
-
expect(noEmailRow.className).toContain("pointer-events-none")
|
|
259
|
-
expect(noEmailRow.textContent).toContain("No email")
|
|
259
|
+
// Server result is shown verbatim even though it doesn't match the query.
|
|
260
|
+
expect(screen.getByText("Server Result")).toBeTruthy()
|
|
260
261
|
})
|
|
261
262
|
|
|
262
|
-
it("
|
|
263
|
-
|
|
263
|
+
it("async mode: does not re-search the same query when only onSearch identity changes", () => {
|
|
264
|
+
const firstOnSearch = vi.fn()
|
|
265
|
+
const secondOnSearch = vi.fn()
|
|
266
|
+
const { rerender } = render(
|
|
264
267
|
<EmailRecipientField
|
|
265
268
|
label="To"
|
|
266
269
|
recipients={[]}
|
|
267
270
|
onRecipientsChange={vi.fn()}
|
|
268
271
|
showPicker
|
|
269
|
-
contacts={
|
|
272
|
+
contacts={[]}
|
|
273
|
+
onSearch={firstOnSearch}
|
|
270
274
|
/>,
|
|
271
275
|
)
|
|
272
276
|
|
|
273
277
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
274
|
-
|
|
275
|
-
expect(
|
|
276
|
-
|
|
278
|
+
// Initial empty query forwarded once.
|
|
279
|
+
expect(firstOnSearch).toHaveBeenCalledTimes(1)
|
|
280
|
+
expect(firstOnSearch).toHaveBeenCalledWith("")
|
|
277
281
|
|
|
278
|
-
|
|
279
|
-
|
|
282
|
+
// A parent re-render that swaps the callback identity (e.g. an inline
|
|
283
|
+
// handler) must NOT retrigger a search for the unchanged query.
|
|
284
|
+
rerender(
|
|
280
285
|
<EmailRecipientField
|
|
281
286
|
label="To"
|
|
282
287
|
recipients={[]}
|
|
283
288
|
onRecipientsChange={vi.fn()}
|
|
284
289
|
showPicker
|
|
285
|
-
contacts={
|
|
290
|
+
contacts={[]}
|
|
291
|
+
onSearch={secondOnSearch}
|
|
286
292
|
/>,
|
|
287
293
|
)
|
|
288
|
-
|
|
289
|
-
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
290
|
-
const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
|
|
291
|
-
|
|
292
|
-
fireEvent.change(search, { target: { value: "Bea" } })
|
|
293
|
-
expect(search.value).toBe("Bea")
|
|
294
|
-
expect(screen.getByText("Bea Buyer")).toBeTruthy()
|
|
295
|
-
expect(screen.queryByText("Alex Admin")).toBeNull()
|
|
294
|
+
expect(secondOnSearch).not.toHaveBeenCalled()
|
|
296
295
|
})
|
|
297
296
|
|
|
298
|
-
it("
|
|
299
|
-
const onChange = vi.fn()
|
|
297
|
+
it("async mode: shows the searching indicator while loading", () => {
|
|
300
298
|
render(
|
|
301
299
|
<EmailRecipientField
|
|
302
300
|
label="To"
|
|
303
301
|
recipients={[]}
|
|
304
|
-
onRecipientsChange={
|
|
302
|
+
onRecipientsChange={vi.fn()}
|
|
305
303
|
showPicker
|
|
306
|
-
contacts={
|
|
304
|
+
contacts={[]}
|
|
305
|
+
onSearch={vi.fn()}
|
|
306
|
+
searchLoading
|
|
307
307
|
/>,
|
|
308
308
|
)
|
|
309
309
|
|
|
310
310
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
fireEvent.change(search, { target: { value: "typed@example.com" } })
|
|
314
|
-
fireEvent.keyDown(search, { key: "Enter" })
|
|
315
|
-
|
|
316
|
-
expect(onChange).toHaveBeenCalledWith([
|
|
317
|
-
{ id: "typed@example.com", email: "typed@example.com", name: "", confirmed: false },
|
|
318
|
-
])
|
|
311
|
+
expect(screen.getByText("Searching contacts...")).toBeTruthy()
|
|
319
312
|
})
|
|
320
313
|
|
|
321
|
-
it("
|
|
314
|
+
it("async mode: prompts to type before searching when query is empty", () => {
|
|
322
315
|
render(
|
|
323
316
|
<EmailRecipientField
|
|
324
317
|
label="To"
|
|
@@ -326,46 +319,35 @@ describe("EmailRecipientField", () => {
|
|
|
326
319
|
onRecipientsChange={vi.fn()}
|
|
327
320
|
showPicker
|
|
328
321
|
contacts={[]}
|
|
322
|
+
onSearch={vi.fn()}
|
|
329
323
|
/>,
|
|
330
324
|
)
|
|
331
325
|
|
|
332
326
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
333
|
-
expect(screen.getByText("
|
|
334
|
-
// The empty state guides the user to type an address instead of looking broken.
|
|
335
|
-
expect(
|
|
336
|
-
screen.getByText(/Type an email address above and press Enter/),
|
|
337
|
-
).toBeTruthy()
|
|
327
|
+
expect(screen.getByText("Type a name or email to search contacts.")).toBeTruthy()
|
|
338
328
|
})
|
|
339
329
|
|
|
340
|
-
it("
|
|
341
|
-
// Reproduces the staging bug: the picker used to render via
|
|
342
|
-
// createPortal(document.body), outside the Dialog's FocusScope, so the
|
|
343
|
-
// search input could not hold focus. The Radix Popover rebuild keeps the
|
|
344
|
-
// input inside its own (parent-pausing) focus scope.
|
|
330
|
+
it("disables rows for added and no-email contacts", () => {
|
|
345
331
|
render(
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
contacts={contacts}
|
|
355
|
-
/>
|
|
356
|
-
</DialogContent>
|
|
357
|
-
</Dialog>,
|
|
332
|
+
<EmailRecipientField
|
|
333
|
+
label="To"
|
|
334
|
+
recipients={[]}
|
|
335
|
+
onRecipientsChange={vi.fn()}
|
|
336
|
+
showPicker
|
|
337
|
+
contacts={contacts}
|
|
338
|
+
addedEmails={new Set(["alex@example.com"])}
|
|
339
|
+
/>,
|
|
358
340
|
)
|
|
359
341
|
|
|
360
342
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
361
|
-
const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
|
|
362
343
|
|
|
363
|
-
|
|
364
|
-
|
|
344
|
+
const options = screen.getAllByRole("option")
|
|
345
|
+
const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
|
|
346
|
+
const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
|
|
365
347
|
|
|
366
|
-
|
|
367
|
-
expect(
|
|
368
|
-
|
|
369
|
-
expect(
|
|
348
|
+
expect(alexRow.className).toContain("pointer-events-none")
|
|
349
|
+
expect(alexRow.textContent).toContain("Added")
|
|
350
|
+
expect(noEmailRow.className).toContain("pointer-events-none")
|
|
351
|
+
expect(noEmailRow.textContent).toContain("No email")
|
|
370
352
|
})
|
|
371
353
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
-
import {
|
|
4
|
+
import { createPortal } from "react-dom"
|
|
5
5
|
import {
|
|
6
6
|
Check,
|
|
7
7
|
ChevronDown,
|
|
@@ -56,6 +56,16 @@ export interface EmailRecipientFieldProps {
|
|
|
56
56
|
addedEmails?: Set<string>
|
|
57
57
|
placeholder?: string
|
|
58
58
|
contactToRecipient?: (contact: SuggestedContact) => RecipientChip
|
|
59
|
+
/**
|
|
60
|
+
* Async search hook. When provided, the contact picker switches to async
|
|
61
|
+
* mode: the search box forwards its query here (the caller is responsible for
|
|
62
|
+
* debouncing and fetching) and `contacts` is treated as the server-filtered
|
|
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).
|
|
65
|
+
*/
|
|
66
|
+
onSearch?: (query: string) => void
|
|
67
|
+
/** Shows a loading indicator in the picker while async results are fetched. */
|
|
68
|
+
searchLoading?: boolean
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
function RecipientChipPill({
|
|
@@ -110,39 +120,120 @@ function RecipientChipPill({
|
|
|
110
120
|
)
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what
|
|
116
|
-
// makes the search input typeable: a plain `createPortal(..., document.body)`
|
|
117
|
-
// element renders outside the Dialog's `DialogContent`, so the Dialog's
|
|
118
|
-
// FocusScope kept yanking focus back (input un-typeable) and its modal
|
|
119
|
-
// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix
|
|
120
|
-
// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus
|
|
121
|
-
// scope, fixing both. See WIT-800 / WIT-770.
|
|
122
|
-
function ContactPickerContents({
|
|
123
|
+
function ContactPickerPopover({
|
|
124
|
+
triggerRef,
|
|
123
125
|
contacts,
|
|
124
126
|
addedEmails,
|
|
125
127
|
onSelect,
|
|
126
128
|
onAddEmail,
|
|
129
|
+
onClose,
|
|
130
|
+
onSearch,
|
|
131
|
+
searchLoading = false,
|
|
127
132
|
}: {
|
|
133
|
+
triggerRef: React.RefObject<HTMLElement | null>
|
|
128
134
|
contacts: SuggestedContact[]
|
|
129
135
|
addedEmails: Set<string>
|
|
130
136
|
onSelect: (contact: SuggestedContact) => void
|
|
131
137
|
onAddEmail: (email: string) => void
|
|
138
|
+
onClose: () => void
|
|
139
|
+
onSearch?: (query: string) => void
|
|
140
|
+
searchLoading?: boolean
|
|
132
141
|
}) {
|
|
142
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
143
|
+
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
133
144
|
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
|
+
|
|
204
|
+
const asyncMode = typeof onSearch === "function"
|
|
205
|
+
|
|
206
|
+
// Keep the latest onSearch in a ref so the search effect below fires only when
|
|
207
|
+
// the query (or async mode) changes, not when the callback identity changes.
|
|
208
|
+
// This lets callers pass an inline handler without triggering duplicate
|
|
209
|
+
// fetches on unrelated parent re-renders.
|
|
210
|
+
const onSearchRef = React.useRef(onSearch)
|
|
211
|
+
React.useEffect(() => {
|
|
212
|
+
onSearchRef.current = onSearch
|
|
213
|
+
}, [onSearch])
|
|
214
|
+
|
|
215
|
+
// Async mode: forward the query upward (caller debounces + fetches) and treat
|
|
216
|
+
// `contacts` as the already server-filtered result set. Local mode: filter the
|
|
217
|
+
// static `contacts` array client-side.
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
if (asyncMode) {
|
|
220
|
+
onSearchRef.current?.(query)
|
|
221
|
+
}
|
|
222
|
+
}, [asyncMode, query])
|
|
134
223
|
|
|
135
224
|
const normalizedQuery = query.trim().toLowerCase()
|
|
136
|
-
const filtered =
|
|
137
|
-
? contacts
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
contact
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
225
|
+
const filtered = asyncMode
|
|
226
|
+
? contacts
|
|
227
|
+
: normalizedQuery
|
|
228
|
+
? contacts.filter((contact) => {
|
|
229
|
+
const email = contactEmail(contact) ?? ""
|
|
230
|
+
return (
|
|
231
|
+
contact.name.toLowerCase().includes(normalizedQuery) ||
|
|
232
|
+
contact.role.toLowerCase().includes(normalizedQuery) ||
|
|
233
|
+
email.toLowerCase().includes(normalizedQuery)
|
|
234
|
+
)
|
|
235
|
+
})
|
|
236
|
+
: contacts
|
|
146
237
|
|
|
147
238
|
const queryIsEmail = isValidEmail(query)
|
|
148
239
|
|
|
@@ -154,11 +245,16 @@ function ContactPickerContents({
|
|
|
154
245
|
}
|
|
155
246
|
}
|
|
156
247
|
|
|
157
|
-
return (
|
|
158
|
-
|
|
248
|
+
return createPortal(
|
|
249
|
+
<div
|
|
250
|
+
ref={containerRef}
|
|
251
|
+
style={style}
|
|
252
|
+
className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
|
|
253
|
+
>
|
|
159
254
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
|
|
160
255
|
<Search className="size-4 text-muted-foreground shrink-0" />
|
|
161
256
|
<input
|
|
257
|
+
ref={searchRef}
|
|
162
258
|
autoFocus
|
|
163
259
|
value={query}
|
|
164
260
|
onChange={(event) => setQuery(event.target.value)}
|
|
@@ -169,18 +265,17 @@ function ContactPickerContents({
|
|
|
169
265
|
</div>
|
|
170
266
|
|
|
171
267
|
<div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
|
|
172
|
-
{
|
|
173
|
-
<div className="px-3 py-
|
|
174
|
-
|
|
175
|
-
No contacts for this account
|
|
176
|
-
</div>
|
|
177
|
-
<div className="mt-1">
|
|
178
|
-
Type an email address above and press Enter to add a recipient.
|
|
179
|
-
</div>
|
|
268
|
+
{searchLoading ? (
|
|
269
|
+
<div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
|
270
|
+
Searching contacts...
|
|
180
271
|
</div>
|
|
181
272
|
) : filtered.length === 0 ? (
|
|
182
273
|
<div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
|
183
|
-
|
|
274
|
+
{asyncMode && normalizedQuery.length === 0 ? (
|
|
275
|
+
<div>Type a name or email to search contacts.</div>
|
|
276
|
+
) : (
|
|
277
|
+
<div>No contact matches ‘{query}’.</div>
|
|
278
|
+
)}
|
|
184
279
|
{queryIsEmail ? (
|
|
185
280
|
<div className="mt-1">Press Enter to add {query}.</div>
|
|
186
281
|
) : null}
|
|
@@ -245,7 +340,8 @@ function ContactPickerContents({
|
|
|
245
340
|
<CornerDownLeft className="size-3 shrink-0" />
|
|
246
341
|
<span>Type an address and press Enter to add someone not listed.</span>
|
|
247
342
|
</div>
|
|
248
|
-
|
|
343
|
+
</div>,
|
|
344
|
+
document.body,
|
|
249
345
|
)
|
|
250
346
|
}
|
|
251
347
|
|
|
@@ -262,9 +358,12 @@ export function EmailRecipientField({
|
|
|
262
358
|
addedEmails,
|
|
263
359
|
placeholder,
|
|
264
360
|
contactToRecipient,
|
|
361
|
+
onSearch,
|
|
362
|
+
searchLoading,
|
|
265
363
|
}: EmailRecipientFieldProps) {
|
|
266
364
|
const [value, setValue] = React.useState("")
|
|
267
365
|
const [pickerOpen, setPickerOpen] = React.useState(false)
|
|
366
|
+
const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
|
|
268
367
|
|
|
269
368
|
const hasUnconfirmed = recipients.some((r) => !r.confirmed)
|
|
270
369
|
const state: "default" | "amber" =
|
|
@@ -365,37 +464,16 @@ export function EmailRecipientField({
|
|
|
365
464
|
{showPicker || showCcBcc ? (
|
|
366
465
|
<div className="flex gap-1.5 mt-2">
|
|
367
466
|
{showPicker ? (
|
|
368
|
-
<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
</PopoverPrimitive.Trigger>
|
|
379
|
-
<PopoverPrimitive.Portal>
|
|
380
|
-
<PopoverPrimitive.Content
|
|
381
|
-
side="bottom"
|
|
382
|
-
align="start"
|
|
383
|
-
sideOffset={4}
|
|
384
|
-
collisionPadding={16}
|
|
385
|
-
className="z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0"
|
|
386
|
-
>
|
|
387
|
-
<ContactPickerContents
|
|
388
|
-
contacts={contacts}
|
|
389
|
-
addedEmails={added}
|
|
390
|
-
onSelect={selectContact}
|
|
391
|
-
onAddEmail={(email) => {
|
|
392
|
-
addEmail(email)
|
|
393
|
-
setPickerOpen(false)
|
|
394
|
-
}}
|
|
395
|
-
/>
|
|
396
|
-
</PopoverPrimitive.Content>
|
|
397
|
-
</PopoverPrimitive.Portal>
|
|
398
|
-
</PopoverPrimitive.Root>
|
|
467
|
+
<button
|
|
468
|
+
ref={contactsTriggerRef}
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={() => setPickerOpen((open) => !open)}
|
|
471
|
+
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]"
|
|
472
|
+
>
|
|
473
|
+
<Users className="size-3" />
|
|
474
|
+
Contacts
|
|
475
|
+
<ChevronDown className="size-3" />
|
|
476
|
+
</button>
|
|
399
477
|
) : null}
|
|
400
478
|
{showCcBcc ? (
|
|
401
479
|
<button
|
|
@@ -409,6 +487,22 @@ export function EmailRecipientField({
|
|
|
409
487
|
) : null}
|
|
410
488
|
</div>
|
|
411
489
|
) : 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}
|
|
412
506
|
</div>
|
|
413
507
|
</div>
|
|
414
508
|
)
|