@handled-ai/design-system 0.18.41 → 0.18.43
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/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/email-recipient-field.d.ts +4 -6
- package/dist/components/email-recipient-field.js +114 -168
- package/dist/components/email-recipient-field.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/email-recipient-field.test.tsx +177 -27
- package/src/components/email-recipient-field.tsx +76 -115
|
@@ -3,7 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { VariantProps } from 'class-variance-authority';
|
|
4
4
|
|
|
5
5
|
declare const badgeVariants: (props?: ({
|
|
6
|
-
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" |
|
|
6
|
+
variant?: "link" | "default" | "secondary" | "destructive" | "outline" | "ghost" | null | undefined;
|
|
7
7
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
8
8
|
declare function Badge({ className, variant, asChild, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
|
|
9
9
|
asChild?: boolean;
|
|
@@ -3,7 +3,7 @@ import * as React from 'react';
|
|
|
3
3
|
import { VariantProps } from 'class-variance-authority';
|
|
4
4
|
|
|
5
5
|
declare const buttonVariants: (props?: ({
|
|
6
|
-
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" |
|
|
6
|
+
variant?: "link" | "default" | "secondary" | "destructive" | "outline" | "ghost" | null | undefined;
|
|
7
7
|
size?: "default" | "sm" | "lg" | "icon" | null | undefined;
|
|
8
8
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
9
9
|
declare function Button({ className, variant, size, asChild, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
|
|
@@ -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,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 { 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
27
|
Check,
|
|
28
28
|
ChevronDown,
|
|
@@ -90,78 +90,26 @@ function RecipientChipPill({
|
|
|
90
90
|
)
|
|
91
91
|
] });
|
|
92
92
|
}
|
|
93
|
-
function
|
|
94
|
-
triggerRef,
|
|
93
|
+
function ContactPickerContents({
|
|
95
94
|
contacts,
|
|
96
95
|
addedEmails,
|
|
97
96
|
onSelect,
|
|
98
97
|
onAddEmail,
|
|
99
|
-
onClose,
|
|
100
98
|
onSearch,
|
|
101
99
|
searchLoading = false
|
|
102
100
|
}) {
|
|
103
|
-
const containerRef = React.useRef(null);
|
|
104
|
-
const searchRef = React.useRef(null);
|
|
105
101
|
const [query, setQuery] = React.useState("");
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
top: -9999,
|
|
109
|
-
left: -9999
|
|
110
|
-
});
|
|
102
|
+
const asyncMode = typeof onSearch === "function";
|
|
103
|
+
const onSearchRef = React.useRef(onSearch);
|
|
111
104
|
React.useEffect(() => {
|
|
112
|
-
|
|
113
|
-
|
|
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]);
|
|
105
|
+
onSearchRef.current = onSearch;
|
|
106
|
+
}, [onSearch]);
|
|
129
107
|
React.useEffect(() => {
|
|
130
108
|
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
|
-
React.useEffect(() => {
|
|
161
109
|
if (asyncMode) {
|
|
162
|
-
|
|
110
|
+
(_a = onSearchRef.current) == null ? void 0 : _a.call(onSearchRef, query);
|
|
163
111
|
}
|
|
164
|
-
}, [asyncMode,
|
|
112
|
+
}, [asyncMode, query]);
|
|
165
113
|
const normalizedQuery = query.trim().toLowerCase();
|
|
166
114
|
const filtered = asyncMode ? contacts : normalizedQuery ? contacts.filter((contact) => {
|
|
167
115
|
var _a;
|
|
@@ -176,82 +124,73 @@ function ContactPickerPopover({
|
|
|
176
124
|
setQuery("");
|
|
177
125
|
}
|
|
178
126
|
}
|
|
179
|
-
return
|
|
180
|
-
/* @__PURE__ */ jsxs(
|
|
181
|
-
"
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
127
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
128
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 px-3 py-2.5 border-b border-border/50", children: [
|
|
129
|
+
/* @__PURE__ */ jsx(Search, { className: "size-4 text-muted-foreground shrink-0" }),
|
|
130
|
+
/* @__PURE__ */ jsx(
|
|
131
|
+
"input",
|
|
132
|
+
{
|
|
133
|
+
autoFocus: true,
|
|
134
|
+
value: query,
|
|
135
|
+
onChange: (event) => setQuery(event.target.value),
|
|
136
|
+
onKeyDown: handleKeyDown,
|
|
137
|
+
className: "flex-1 text-[13px] bg-transparent outline-none",
|
|
138
|
+
placeholder: "Search contacts..."
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
] }),
|
|
142
|
+
/* @__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: [
|
|
143
|
+
/* @__PURE__ */ jsx("div", { className: "font-medium text-foreground/80", children: "No contacts for this account" }),
|
|
144
|
+
/* @__PURE__ */ jsx("div", { className: "mt-1", children: "Type an email address above and press Enter to add a recipient." })
|
|
145
|
+
] }) : filtered.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "px-3 py-4 text-center text-[13px] text-muted-foreground", children: [
|
|
146
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
147
|
+
"No contact matches \u2018",
|
|
148
|
+
query,
|
|
149
|
+
"\u2019."
|
|
150
|
+
] }),
|
|
151
|
+
queryIsEmail ? /* @__PURE__ */ jsxs("div", { className: "mt-1", children: [
|
|
152
|
+
"Press Enter to add ",
|
|
153
|
+
query,
|
|
154
|
+
"."
|
|
155
|
+
] }) : null
|
|
156
|
+
] }) : filtered.map((contact, index) => {
|
|
157
|
+
const email = contactEmail(contact);
|
|
158
|
+
const noEmail = !email || !isValidEmail(email);
|
|
159
|
+
const alreadyAdded = email ? addedEmails.has(email.toLowerCase()) : false;
|
|
160
|
+
const disabled = noEmail || alreadyAdded;
|
|
161
|
+
return /* @__PURE__ */ jsxs(
|
|
162
|
+
"div",
|
|
163
|
+
{
|
|
164
|
+
role: "option",
|
|
165
|
+
"aria-selected": false,
|
|
166
|
+
"aria-disabled": disabled,
|
|
167
|
+
onClick: () => {
|
|
168
|
+
if (!disabled) onSelect(contact);
|
|
169
|
+
},
|
|
170
|
+
className: cn(
|
|
171
|
+
"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
|
|
172
|
+
disabled && "opacity-45 pointer-events-none"
|
|
173
|
+
),
|
|
174
|
+
children: [
|
|
175
|
+
/* @__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 : "?") }),
|
|
176
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
177
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
|
|
178
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
|
|
179
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
|
|
180
|
+
] }),
|
|
181
|
+
email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
|
|
207
182
|
] }),
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"div",
|
|
220
|
-
{
|
|
221
|
-
role: "option",
|
|
222
|
-
"aria-selected": false,
|
|
223
|
-
"aria-disabled": disabled,
|
|
224
|
-
onClick: () => {
|
|
225
|
-
if (!disabled) onSelect(contact);
|
|
226
|
-
},
|
|
227
|
-
className: cn(
|
|
228
|
-
"flex items-center gap-2.5 px-2 py-1.5 rounded-md cursor-pointer hover:bg-muted/60",
|
|
229
|
-
disabled && "opacity-45 pointer-events-none"
|
|
230
|
-
),
|
|
231
|
-
children: [
|
|
232
|
-
/* @__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 : "?") }),
|
|
233
|
-
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
234
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
|
|
235
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[13px] font-medium text-foreground", children: contact.name }),
|
|
236
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: contact.role })
|
|
237
|
-
] }),
|
|
238
|
-
email ? /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted-foreground", children: email }) : null
|
|
239
|
-
] }),
|
|
240
|
-
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
|
|
241
|
-
]
|
|
242
|
-
},
|
|
243
|
-
`${contact.name}-${email != null ? email : index}`
|
|
244
|
-
);
|
|
245
|
-
}) }),
|
|
246
|
-
/* @__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: [
|
|
247
|
-
/* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
|
|
248
|
-
/* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
|
|
249
|
-
] })
|
|
250
|
-
]
|
|
251
|
-
}
|
|
252
|
-
),
|
|
253
|
-
document.body
|
|
254
|
-
);
|
|
183
|
+
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
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
`${contact.name}-${email != null ? email : index}`
|
|
187
|
+
);
|
|
188
|
+
}) }),
|
|
189
|
+
/* @__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: [
|
|
190
|
+
/* @__PURE__ */ jsx(CornerDownLeft, { className: "size-3 shrink-0" }),
|
|
191
|
+
/* @__PURE__ */ jsx("span", { children: "Type an address and press Enter to add someone not listed." })
|
|
192
|
+
] })
|
|
193
|
+
] });
|
|
255
194
|
}
|
|
256
195
|
function EmailRecipientField({
|
|
257
196
|
label,
|
|
@@ -271,7 +210,6 @@ function EmailRecipientField({
|
|
|
271
210
|
}) {
|
|
272
211
|
const [value, setValue] = React.useState("");
|
|
273
212
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
274
|
-
const contactsTriggerRef = React.useRef(null);
|
|
275
213
|
const hasUnconfirmed = recipients.some((r) => !r.confirmed);
|
|
276
214
|
const state = amber && hasUnconfirmed ? "amber" : "default";
|
|
277
215
|
const amberRow = state === "amber";
|
|
@@ -361,20 +299,44 @@ function EmailRecipientField({
|
|
|
361
299
|
)
|
|
362
300
|
] }),
|
|
363
301
|
showPicker || showCcBcc ? /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 mt-2", children: [
|
|
364
|
-
showPicker ? /* @__PURE__ */ jsxs(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
|
|
302
|
+
showPicker ? /* @__PURE__ */ jsxs(PopoverPrimitive.Root, { open: pickerOpen, onOpenChange: setPickerOpen, children: [
|
|
303
|
+
/* @__PURE__ */ jsx(PopoverPrimitive.Trigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
304
|
+
"button",
|
|
305
|
+
{
|
|
306
|
+
type: "button",
|
|
307
|
+
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]",
|
|
308
|
+
children: [
|
|
309
|
+
/* @__PURE__ */ jsx(Users, { className: "size-3" }),
|
|
310
|
+
"Contacts",
|
|
311
|
+
/* @__PURE__ */ jsx(ChevronDown, { className: "size-3" })
|
|
312
|
+
]
|
|
313
|
+
}
|
|
314
|
+
) }),
|
|
315
|
+
/* @__PURE__ */ jsx(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx(
|
|
316
|
+
PopoverPrimitive.Content,
|
|
317
|
+
{
|
|
318
|
+
side: "bottom",
|
|
319
|
+
align: "start",
|
|
320
|
+
sideOffset: 4,
|
|
321
|
+
collisionPadding: 16,
|
|
322
|
+
className: "z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0",
|
|
323
|
+
children: /* @__PURE__ */ jsx(
|
|
324
|
+
ContactPickerContents,
|
|
325
|
+
{
|
|
326
|
+
contacts,
|
|
327
|
+
addedEmails: added,
|
|
328
|
+
onSelect: selectContact,
|
|
329
|
+
onAddEmail: (email) => {
|
|
330
|
+
addEmail(email);
|
|
331
|
+
setPickerOpen(false);
|
|
332
|
+
},
|
|
333
|
+
onSearch,
|
|
334
|
+
searchLoading
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
) })
|
|
339
|
+
] }) : null,
|
|
378
340
|
showCcBcc ? /* @__PURE__ */ jsxs(
|
|
379
341
|
"button",
|
|
380
342
|
{
|
|
@@ -387,23 +349,7 @@ function EmailRecipientField({
|
|
|
387
349
|
]
|
|
388
350
|
}
|
|
389
351
|
) : null
|
|
390
|
-
] }) : null
|
|
391
|
-
pickerOpen ? /* @__PURE__ */ jsx(
|
|
392
|
-
ContactPickerPopover,
|
|
393
|
-
{
|
|
394
|
-
triggerRef: contactsTriggerRef,
|
|
395
|
-
contacts,
|
|
396
|
-
addedEmails: added,
|
|
397
|
-
onSelect: selectContact,
|
|
398
|
-
onAddEmail: (email) => {
|
|
399
|
-
addEmail(email);
|
|
400
|
-
setPickerOpen(false);
|
|
401
|
-
},
|
|
402
|
-
onClose: () => setPickerOpen(false),
|
|
403
|
-
onSearch,
|
|
404
|
-
searchLoading
|
|
405
|
-
}
|
|
406
|
-
) : null
|
|
352
|
+
] }) : null
|
|
407
353
|
] })
|
|
408
354
|
]
|
|
409
355
|
}
|
|
@@ -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 // 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 onSearch?.(query)\n }\n }, [asyncMode, onSearch, 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 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 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 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 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 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 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":[]}
|
|
@@ -12,7 +12,7 @@ import { VariantProps } from 'class-variance-authority';
|
|
|
12
12
|
*/
|
|
13
13
|
type PillStatus = "success" | "warning" | "error" | "neutral" | "info";
|
|
14
14
|
declare const pillVariants: (props?: ({
|
|
15
|
-
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost" | "
|
|
15
|
+
variant?: "error" | "default" | "secondary" | "destructive" | "outline" | "ghost" | "neutral" | "info" | "success" | "warning" | null | undefined;
|
|
16
16
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
17
17
|
interface PillProps extends React.ComponentProps<"span">, VariantProps<typeof pillVariants> {
|
|
18
18
|
}
|
|
@@ -5,7 +5,7 @@ import { Tabs as Tabs$1 } from 'radix-ui';
|
|
|
5
5
|
|
|
6
6
|
declare function Tabs({ className, orientation, ...props }: React.ComponentProps<typeof Tabs$1.Root>): React.JSX.Element;
|
|
7
7
|
declare const tabsListVariants: (props?: ({
|
|
8
|
-
variant?: "
|
|
8
|
+
variant?: "line" | "default" | null | undefined;
|
|
9
9
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
10
10
|
declare function TabsList({ className, variant, ...props }: React.ComponentProps<typeof Tabs$1.List> & VariantProps<typeof tabsListVariants>): React.JSX.Element;
|
|
11
11
|
declare function TabsTrigger({ className, ...props }: React.ComponentProps<typeof Tabs$1.Trigger>): React.JSX.Element;
|
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(() => {
|
|
@@ -230,37 +235,170 @@ describe("EmailRecipientField", () => {
|
|
|
230
235
|
])
|
|
231
236
|
})
|
|
232
237
|
|
|
238
|
+
it("disables rows for added and no-email contacts", () => {
|
|
239
|
+
render(
|
|
240
|
+
<EmailRecipientField
|
|
241
|
+
label="To"
|
|
242
|
+
recipients={[]}
|
|
243
|
+
onRecipientsChange={vi.fn()}
|
|
244
|
+
showPicker
|
|
245
|
+
contacts={contacts}
|
|
246
|
+
addedEmails={new Set(["alex@example.com"])}
|
|
247
|
+
/>,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
251
|
+
|
|
252
|
+
const options = screen.getAllByRole("option")
|
|
253
|
+
const alexRow = options.find((o) => o.textContent?.includes("Alex Admin"))!
|
|
254
|
+
const noEmailRow = options.find((o) => o.textContent?.includes("No Email"))!
|
|
255
|
+
|
|
256
|
+
expect(alexRow.className).toContain("pointer-events-none")
|
|
257
|
+
expect(alexRow.textContent).toContain("Added")
|
|
258
|
+
expect(noEmailRow.className).toContain("pointer-events-none")
|
|
259
|
+
expect(noEmailRow.textContent).toContain("No email")
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("autofocuses the search input when the picker opens", () => {
|
|
263
|
+
render(
|
|
264
|
+
<EmailRecipientField
|
|
265
|
+
label="To"
|
|
266
|
+
recipients={[]}
|
|
267
|
+
onRecipientsChange={vi.fn()}
|
|
268
|
+
showPicker
|
|
269
|
+
contacts={contacts}
|
|
270
|
+
/>,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
274
|
+
const search = screen.getByPlaceholderText("Search contacts...")
|
|
275
|
+
expect(document.activeElement).toBe(search)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("accepts typing in the search box and filters the list", () => {
|
|
279
|
+
render(
|
|
280
|
+
<EmailRecipientField
|
|
281
|
+
label="To"
|
|
282
|
+
recipients={[]}
|
|
283
|
+
onRecipientsChange={vi.fn()}
|
|
284
|
+
showPicker
|
|
285
|
+
contacts={contacts}
|
|
286
|
+
/>,
|
|
287
|
+
)
|
|
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()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it("adds a typed email from the picker search box on Enter", () => {
|
|
299
|
+
const onChange = vi.fn()
|
|
300
|
+
render(
|
|
301
|
+
<EmailRecipientField
|
|
302
|
+
label="To"
|
|
303
|
+
recipients={[]}
|
|
304
|
+
onRecipientsChange={onChange}
|
|
305
|
+
showPicker
|
|
306
|
+
contacts={contacts}
|
|
307
|
+
/>,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
311
|
+
const search = screen.getByPlaceholderText("Search contacts...")
|
|
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
|
+
])
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it("shows a clear empty state when there are no contacts", () => {
|
|
322
|
+
render(
|
|
323
|
+
<EmailRecipientField
|
|
324
|
+
label="To"
|
|
325
|
+
recipients={[]}
|
|
326
|
+
onRecipientsChange={vi.fn()}
|
|
327
|
+
showPicker
|
|
328
|
+
contacts={[]}
|
|
329
|
+
/>,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
333
|
+
expect(screen.getByText("No contacts for this account")).toBeTruthy()
|
|
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()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it("remains typeable when rendered inside a modal Dialog (WIT-800 focus trap)", () => {
|
|
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.
|
|
345
|
+
render(
|
|
346
|
+
<Dialog open>
|
|
347
|
+
<DialogContent aria-describedby={undefined}>
|
|
348
|
+
<DialogTitle>Compose</DialogTitle>
|
|
349
|
+
<EmailRecipientField
|
|
350
|
+
label="To"
|
|
351
|
+
recipients={[]}
|
|
352
|
+
onRecipientsChange={vi.fn()}
|
|
353
|
+
showPicker
|
|
354
|
+
contacts={contacts}
|
|
355
|
+
/>
|
|
356
|
+
</DialogContent>
|
|
357
|
+
</Dialog>,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
361
|
+
const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
|
|
362
|
+
|
|
363
|
+
search.focus()
|
|
364
|
+
expect(document.activeElement).toBe(search)
|
|
365
|
+
|
|
366
|
+
fireEvent.change(search, { target: { value: "Alex" } })
|
|
367
|
+
expect(search.value).toBe("Alex")
|
|
368
|
+
const listbox = screen.getByRole("listbox")
|
|
369
|
+
expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
|
|
370
|
+
})
|
|
371
|
+
|
|
233
372
|
it("async mode: forwards the query to onSearch and skips client-side filtering", () => {
|
|
234
373
|
const onSearch = vi.fn()
|
|
235
|
-
// In async mode the
|
|
236
|
-
//
|
|
237
|
-
const serverResults: SuggestedContact[] = [
|
|
238
|
-
{ name: "Server Result", role: "VP", email: "server@example.com", confirmed: true },
|
|
239
|
-
]
|
|
374
|
+
// In async mode `contacts` is the already server-filtered result set, so
|
|
375
|
+
// every supplied contact should render regardless of the typed query.
|
|
240
376
|
render(
|
|
241
377
|
<EmailRecipientField
|
|
242
378
|
label="To"
|
|
243
379
|
recipients={[]}
|
|
244
380
|
onRecipientsChange={vi.fn()}
|
|
245
381
|
showPicker
|
|
246
|
-
contacts={
|
|
382
|
+
contacts={contacts}
|
|
247
383
|
onSearch={onSearch}
|
|
248
384
|
/>,
|
|
249
385
|
)
|
|
250
386
|
|
|
251
387
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
252
|
-
// Opening
|
|
388
|
+
// Opening forwards the initial empty query.
|
|
253
389
|
expect(onSearch).toHaveBeenCalledWith("")
|
|
254
390
|
|
|
255
|
-
const
|
|
256
|
-
fireEvent.change(
|
|
257
|
-
expect(onSearch).toHaveBeenCalledWith("zzz no local match")
|
|
391
|
+
const search = screen.getByPlaceholderText("Search contacts...")
|
|
392
|
+
fireEvent.change(search, { target: { value: "zzz" } })
|
|
258
393
|
|
|
259
|
-
|
|
260
|
-
|
|
394
|
+
expect(onSearch).toHaveBeenCalledWith("zzz")
|
|
395
|
+
const listbox = screen.getByRole("listbox")
|
|
396
|
+
// No client-side filtering: server-provided contacts still show.
|
|
397
|
+
expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
|
|
398
|
+
expect(within(listbox).getByText("Bea Buyer")).toBeTruthy()
|
|
261
399
|
})
|
|
262
400
|
|
|
263
|
-
it("async mode: shows
|
|
401
|
+
it("async mode: shows a searching indicator while results load", () => {
|
|
264
402
|
render(
|
|
265
403
|
<EmailRecipientField
|
|
266
404
|
label="To"
|
|
@@ -277,7 +415,7 @@ describe("EmailRecipientField", () => {
|
|
|
277
415
|
expect(screen.getByText("Searching contacts...")).toBeTruthy()
|
|
278
416
|
})
|
|
279
417
|
|
|
280
|
-
it("async mode: prompts to type before searching when query is empty", () => {
|
|
418
|
+
it("async mode: prompts to type before searching when the query is empty", () => {
|
|
281
419
|
render(
|
|
282
420
|
<EmailRecipientField
|
|
283
421
|
label="To"
|
|
@@ -290,30 +428,42 @@ describe("EmailRecipientField", () => {
|
|
|
290
428
|
)
|
|
291
429
|
|
|
292
430
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
293
|
-
expect(
|
|
431
|
+
expect(
|
|
432
|
+
screen.getByText("Type a name or email to search contacts."),
|
|
433
|
+
).toBeTruthy()
|
|
294
434
|
})
|
|
295
435
|
|
|
296
|
-
it("
|
|
297
|
-
|
|
436
|
+
it("async mode: does not re-search the same query when only onSearch identity changes", () => {
|
|
437
|
+
const firstOnSearch = vi.fn()
|
|
438
|
+
const { rerender } = render(
|
|
298
439
|
<EmailRecipientField
|
|
299
440
|
label="To"
|
|
300
441
|
recipients={[]}
|
|
301
442
|
onRecipientsChange={vi.fn()}
|
|
302
443
|
showPicker
|
|
303
|
-
contacts={
|
|
304
|
-
|
|
444
|
+
contacts={[]}
|
|
445
|
+
onSearch={firstOnSearch}
|
|
305
446
|
/>,
|
|
306
447
|
)
|
|
307
448
|
|
|
308
449
|
fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
|
|
450
|
+
expect(firstOnSearch).toHaveBeenCalledTimes(1)
|
|
451
|
+
expect(firstOnSearch).toHaveBeenCalledWith("")
|
|
309
452
|
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
453
|
+
const secondOnSearch = vi.fn()
|
|
454
|
+
rerender(
|
|
455
|
+
<EmailRecipientField
|
|
456
|
+
label="To"
|
|
457
|
+
recipients={[]}
|
|
458
|
+
onRecipientsChange={vi.fn()}
|
|
459
|
+
showPicker
|
|
460
|
+
contacts={[]}
|
|
461
|
+
onSearch={secondOnSearch}
|
|
462
|
+
/>,
|
|
463
|
+
)
|
|
313
464
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
expect(
|
|
317
|
-
expect(noEmailRow.textContent).toContain("No email")
|
|
465
|
+
// Query is unchanged (""), so swapping the callback identity must not
|
|
466
|
+
// trigger another search.
|
|
467
|
+
expect(secondOnSearch).not.toHaveBeenCalled()
|
|
318
468
|
})
|
|
319
469
|
})
|
|
@@ -1,7 +1,7 @@
|
|
|
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
6
|
Check,
|
|
7
7
|
ChevronDown,
|
|
@@ -57,14 +57,12 @@ export interface EmailRecipientFieldProps {
|
|
|
57
57
|
placeholder?: string
|
|
58
58
|
contactToRecipient?: (contact: SuggestedContact) => RecipientChip
|
|
59
59
|
/**
|
|
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).
|
|
60
|
+
* Async search mode. When provided, the picker forwards the typed query to
|
|
61
|
+
* this callback (the caller debounces + fetches) and treats `contacts` as the
|
|
62
|
+
* already server-filtered result set instead of filtering it client-side.
|
|
65
63
|
*/
|
|
66
64
|
onSearch?: (query: string) => void
|
|
67
|
-
/** Shows a
|
|
65
|
+
/** Shows a "Searching contacts..." indicator while async results load. */
|
|
68
66
|
searchLoading?: boolean
|
|
69
67
|
}
|
|
70
68
|
|
|
@@ -120,97 +118,51 @@ function RecipientChipPill({
|
|
|
120
118
|
)
|
|
121
119
|
}
|
|
122
120
|
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
// Contents of the contact picker dropdown. Rendered inside a Radix
|
|
122
|
+
// `Popover.Content` so its focus scope pushes onto the focus-scope stack and
|
|
123
|
+
// PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what
|
|
124
|
+
// makes the search input typeable: a plain `createPortal(..., document.body)`
|
|
125
|
+
// element renders outside the Dialog's `DialogContent`, so the Dialog's
|
|
126
|
+
// FocusScope kept yanking focus back (input un-typeable) and its modal
|
|
127
|
+
// `pointer-events: none` on <body> left the portal click-dead. A stacked Radix
|
|
128
|
+
// Popover layer gets `pointer-events: auto` and its own (paused-parent) focus
|
|
129
|
+
// scope, fixing both. See WIT-800 / WIT-770.
|
|
130
|
+
function ContactPickerContents({
|
|
125
131
|
contacts,
|
|
126
132
|
addedEmails,
|
|
127
133
|
onSelect,
|
|
128
134
|
onAddEmail,
|
|
129
|
-
onClose,
|
|
130
135
|
onSearch,
|
|
131
136
|
searchLoading = false,
|
|
132
137
|
}: {
|
|
133
|
-
triggerRef: React.RefObject<HTMLElement | null>
|
|
134
138
|
contacts: SuggestedContact[]
|
|
135
139
|
addedEmails: Set<string>
|
|
136
140
|
onSelect: (contact: SuggestedContact) => void
|
|
137
141
|
onAddEmail: (email: string) => void
|
|
138
|
-
onClose: () => void
|
|
139
142
|
onSearch?: (query: string) => void
|
|
140
143
|
searchLoading?: boolean
|
|
141
144
|
}) {
|
|
142
|
-
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
143
|
-
const searchRef = React.useRef<HTMLInputElement>(null)
|
|
144
145
|
const [query, setQuery] = React.useState("")
|
|
145
|
-
const [style, setStyle] = React.useState<React.CSSProperties>({
|
|
146
|
-
position: "fixed",
|
|
147
|
-
top: -9999,
|
|
148
|
-
left: -9999,
|
|
149
|
-
})
|
|
150
146
|
|
|
151
|
-
|
|
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])
|
|
147
|
+
const asyncMode = typeof onSearch === "function"
|
|
169
148
|
|
|
149
|
+
// Keep the latest onSearch in a ref so the search effect below fires only when
|
|
150
|
+
// the query (or async mode) changes, not when the callback identity changes.
|
|
151
|
+
// This lets callers pass an inline handler without triggering duplicate
|
|
152
|
+
// fetches on unrelated parent re-renders.
|
|
153
|
+
const onSearchRef = React.useRef(onSearch)
|
|
170
154
|
React.useEffect(() => {
|
|
171
|
-
|
|
172
|
-
|
|
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"
|
|
155
|
+
onSearchRef.current = onSearch
|
|
156
|
+
}, [onSearch])
|
|
205
157
|
|
|
206
158
|
// Async mode: forward the query upward (caller debounces + fetches) and treat
|
|
207
159
|
// `contacts` as the already server-filtered result set. Local mode: filter the
|
|
208
160
|
// static `contacts` array client-side.
|
|
209
161
|
React.useEffect(() => {
|
|
210
162
|
if (asyncMode) {
|
|
211
|
-
|
|
163
|
+
onSearchRef.current?.(query)
|
|
212
164
|
}
|
|
213
|
-
}, [asyncMode,
|
|
165
|
+
}, [asyncMode, query])
|
|
214
166
|
|
|
215
167
|
const normalizedQuery = query.trim().toLowerCase()
|
|
216
168
|
const filtered = asyncMode
|
|
@@ -236,16 +188,11 @@ function ContactPickerPopover({
|
|
|
236
188
|
}
|
|
237
189
|
}
|
|
238
190
|
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
ref={containerRef}
|
|
242
|
-
style={style}
|
|
243
|
-
className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
|
|
244
|
-
>
|
|
191
|
+
return (
|
|
192
|
+
<>
|
|
245
193
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
|
|
246
194
|
<Search className="size-4 text-muted-foreground shrink-0" />
|
|
247
195
|
<input
|
|
248
|
-
ref={searchRef}
|
|
249
196
|
autoFocus
|
|
250
197
|
value={query}
|
|
251
198
|
onChange={(event) => setQuery(event.target.value)}
|
|
@@ -257,16 +204,25 @@ function ContactPickerPopover({
|
|
|
257
204
|
|
|
258
205
|
<div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
|
|
259
206
|
{searchLoading ? (
|
|
260
|
-
<div className="px-3 py-
|
|
207
|
+
<div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
|
|
261
208
|
Searching contacts...
|
|
262
209
|
</div>
|
|
210
|
+
) : asyncMode && normalizedQuery.length === 0 ? (
|
|
211
|
+
<div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
|
|
212
|
+
Type a name or email to search contacts.
|
|
213
|
+
</div>
|
|
214
|
+
) : !asyncMode && contacts.length === 0 ? (
|
|
215
|
+
<div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
|
|
216
|
+
<div className="font-medium text-foreground/80">
|
|
217
|
+
No contacts for this account
|
|
218
|
+
</div>
|
|
219
|
+
<div className="mt-1">
|
|
220
|
+
Type an email address above and press Enter to add a recipient.
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
263
223
|
) : filtered.length === 0 ? (
|
|
264
224
|
<div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
|
265
|
-
|
|
266
|
-
<div>Type a name or email to search contacts.</div>
|
|
267
|
-
) : (
|
|
268
|
-
<div>No contact matches ‘{query}’.</div>
|
|
269
|
-
)}
|
|
225
|
+
<div>No contact matches ‘{query}’.</div>
|
|
270
226
|
{queryIsEmail ? (
|
|
271
227
|
<div className="mt-1">Press Enter to add {query}.</div>
|
|
272
228
|
) : null}
|
|
@@ -331,8 +287,7 @@ function ContactPickerPopover({
|
|
|
331
287
|
<CornerDownLeft className="size-3 shrink-0" />
|
|
332
288
|
<span>Type an address and press Enter to add someone not listed.</span>
|
|
333
289
|
</div>
|
|
334
|
-
|
|
335
|
-
document.body,
|
|
290
|
+
</>
|
|
336
291
|
)
|
|
337
292
|
}
|
|
338
293
|
|
|
@@ -354,7 +309,6 @@ export function EmailRecipientField({
|
|
|
354
309
|
}: EmailRecipientFieldProps) {
|
|
355
310
|
const [value, setValue] = React.useState("")
|
|
356
311
|
const [pickerOpen, setPickerOpen] = React.useState(false)
|
|
357
|
-
const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
|
|
358
312
|
|
|
359
313
|
const hasUnconfirmed = recipients.some((r) => !r.confirmed)
|
|
360
314
|
const state: "default" | "amber" =
|
|
@@ -455,16 +409,39 @@ export function EmailRecipientField({
|
|
|
455
409
|
{showPicker || showCcBcc ? (
|
|
456
410
|
<div className="flex gap-1.5 mt-2">
|
|
457
411
|
{showPicker ? (
|
|
458
|
-
<
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
412
|
+
<PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
413
|
+
<PopoverPrimitive.Trigger asChild>
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
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]"
|
|
417
|
+
>
|
|
418
|
+
<Users className="size-3" />
|
|
419
|
+
Contacts
|
|
420
|
+
<ChevronDown className="size-3" />
|
|
421
|
+
</button>
|
|
422
|
+
</PopoverPrimitive.Trigger>
|
|
423
|
+
<PopoverPrimitive.Portal>
|
|
424
|
+
<PopoverPrimitive.Content
|
|
425
|
+
side="bottom"
|
|
426
|
+
align="start"
|
|
427
|
+
sideOffset={4}
|
|
428
|
+
collisionPadding={16}
|
|
429
|
+
className="z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0"
|
|
430
|
+
>
|
|
431
|
+
<ContactPickerContents
|
|
432
|
+
contacts={contacts}
|
|
433
|
+
addedEmails={added}
|
|
434
|
+
onSelect={selectContact}
|
|
435
|
+
onAddEmail={(email) => {
|
|
436
|
+
addEmail(email)
|
|
437
|
+
setPickerOpen(false)
|
|
438
|
+
}}
|
|
439
|
+
onSearch={onSearch}
|
|
440
|
+
searchLoading={searchLoading}
|
|
441
|
+
/>
|
|
442
|
+
</PopoverPrimitive.Content>
|
|
443
|
+
</PopoverPrimitive.Portal>
|
|
444
|
+
</PopoverPrimitive.Root>
|
|
468
445
|
) : null}
|
|
469
446
|
{showCcBcc ? (
|
|
470
447
|
<button
|
|
@@ -478,22 +455,6 @@ export function EmailRecipientField({
|
|
|
478
455
|
) : null}
|
|
479
456
|
</div>
|
|
480
457
|
) : null}
|
|
481
|
-
|
|
482
|
-
{pickerOpen ? (
|
|
483
|
-
<ContactPickerPopover
|
|
484
|
-
triggerRef={contactsTriggerRef}
|
|
485
|
-
contacts={contacts}
|
|
486
|
-
addedEmails={added}
|
|
487
|
-
onSelect={selectContact}
|
|
488
|
-
onAddEmail={(email) => {
|
|
489
|
-
addEmail(email)
|
|
490
|
-
setPickerOpen(false)
|
|
491
|
-
}}
|
|
492
|
-
onClose={() => setPickerOpen(false)}
|
|
493
|
-
onSearch={onSearch}
|
|
494
|
-
searchLoading={searchLoading}
|
|
495
|
-
/>
|
|
496
|
-
) : null}
|
|
497
458
|
</div>
|
|
498
459
|
</div>
|
|
499
460
|
)
|