@handled-ai/design-system 0.18.42 → 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.
@@ -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" | "link" | null | undefined;
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" | "link" | null | undefined;
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 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).
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 loading indicator in the picker while async results are fetched. */
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 { createPortal } from "react-dom";
25
+ import { Popover as PopoverPrimitive } from "radix-ui";
26
26
  import {
27
27
  Check,
28
28
  ChevronDown,
@@ -90,72 +90,15 @@ function RecipientChipPill({
90
90
  )
91
91
  ] });
92
92
  }
93
- function ContactPickerPopover({
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 [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
102
  const asyncMode = typeof onSearch === "function";
160
103
  const onSearchRef = React.useRef(onSearch);
161
104
  React.useEffect(() => {
@@ -181,82 +124,73 @@ function ContactPickerPopover({
181
124
  setQuery("");
182
125
  }
183
126
  }
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."
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
212
182
  ] }),
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
- );
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
+ ] });
260
194
  }
261
195
  function EmailRecipientField({
262
196
  label,
@@ -276,7 +210,6 @@ function EmailRecipientField({
276
210
  }) {
277
211
  const [value, setValue] = React.useState("");
278
212
  const [pickerOpen, setPickerOpen] = React.useState(false);
279
- const contactsTriggerRef = React.useRef(null);
280
213
  const hasUnconfirmed = recipients.some((r) => !r.confirmed);
281
214
  const state = amber && hasUnconfirmed ? "amber" : "default";
282
215
  const amberRow = state === "amber";
@@ -366,20 +299,44 @@ function EmailRecipientField({
366
299
  )
367
300
  ] }),
368
301
  showPicker || showCcBcc ? /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 mt-2", children: [
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,
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,
383
340
  showCcBcc ? /* @__PURE__ */ jsxs(
384
341
  "button",
385
342
  {
@@ -392,23 +349,7 @@ function EmailRecipientField({
392
349
  ]
393
350
  }
394
351
  ) : 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
352
+ ] }) : null
412
353
  ] })
413
354
  ]
414
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 // 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 &lsquo;{query}&rsquo;.</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 &lsquo;{query}&rsquo;.</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" | "error" | "neutral" | "info" | "success" | "warning" | null | undefined;
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?: "default" | "line" | null | undefined;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.18.42",
3
+ "version": "0.18.43",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -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,71 +235,170 @@ describe("EmailRecipientField", () => {
230
235
  ])
231
236
  })
232
237
 
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
- ]
238
+ it("disables rows for added and no-email contacts", () => {
240
239
  render(
241
240
  <EmailRecipientField
242
241
  label="To"
243
242
  recipients={[]}
244
243
  onRecipientsChange={vi.fn()}
245
244
  showPicker
246
- contacts={serverResults}
247
- onSearch={onSearch}
245
+ contacts={contacts}
246
+ addedEmails={new Set(["alex@example.com"])}
248
247
  />,
249
248
  )
250
249
 
251
250
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
252
- // Opening the picker forwards the initial (empty) query.
253
- expect(onSearch).toHaveBeenCalledWith("")
254
251
 
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")
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"))!
258
255
 
259
- // Server result is shown verbatim even though it doesn't match the query.
260
- expect(screen.getByText("Server Result")).toBeTruthy()
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")
261
260
  })
262
261
 
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(
262
+ it("autofocuses the search input when the picker opens", () => {
263
+ render(
267
264
  <EmailRecipientField
268
265
  label="To"
269
266
  recipients={[]}
270
267
  onRecipientsChange={vi.fn()}
271
268
  showPicker
272
- contacts={[]}
273
- onSearch={firstOnSearch}
269
+ contacts={contacts}
274
270
  />,
275
271
  )
276
272
 
277
273
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
278
- // Initial empty query forwarded once.
279
- expect(firstOnSearch).toHaveBeenCalledTimes(1)
280
- expect(firstOnSearch).toHaveBeenCalledWith("")
274
+ const search = screen.getByPlaceholderText("Search contacts...")
275
+ expect(document.activeElement).toBe(search)
276
+ })
281
277
 
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(
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(
285
323
  <EmailRecipientField
286
324
  label="To"
287
325
  recipients={[]}
288
326
  onRecipientsChange={vi.fn()}
289
327
  showPicker
290
328
  contacts={[]}
291
- onSearch={secondOnSearch}
292
329
  />,
293
330
  )
294
- expect(secondOnSearch).not.toHaveBeenCalled()
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
+
372
+ it("async mode: forwards the query to onSearch and skips client-side filtering", () => {
373
+ const onSearch = vi.fn()
374
+ // In async mode `contacts` is the already server-filtered result set, so
375
+ // every supplied contact should render regardless of the typed query.
376
+ render(
377
+ <EmailRecipientField
378
+ label="To"
379
+ recipients={[]}
380
+ onRecipientsChange={vi.fn()}
381
+ showPicker
382
+ contacts={contacts}
383
+ onSearch={onSearch}
384
+ />,
385
+ )
386
+
387
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
388
+ // Opening forwards the initial empty query.
389
+ expect(onSearch).toHaveBeenCalledWith("")
390
+
391
+ const search = screen.getByPlaceholderText("Search contacts...")
392
+ fireEvent.change(search, { target: { value: "zzz" } })
393
+
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()
295
399
  })
296
400
 
297
- it("async mode: shows the searching indicator while loading", () => {
401
+ it("async mode: shows a searching indicator while results load", () => {
298
402
  render(
299
403
  <EmailRecipientField
300
404
  label="To"
@@ -311,7 +415,7 @@ describe("EmailRecipientField", () => {
311
415
  expect(screen.getByText("Searching contacts...")).toBeTruthy()
312
416
  })
313
417
 
314
- 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", () => {
315
419
  render(
316
420
  <EmailRecipientField
317
421
  label="To"
@@ -324,30 +428,42 @@ describe("EmailRecipientField", () => {
324
428
  )
325
429
 
326
430
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
327
- expect(screen.getByText("Type a name or email to search contacts.")).toBeTruthy()
431
+ expect(
432
+ screen.getByText("Type a name or email to search contacts."),
433
+ ).toBeTruthy()
328
434
  })
329
435
 
330
- it("disables rows for added and no-email contacts", () => {
331
- render(
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(
332
439
  <EmailRecipientField
333
440
  label="To"
334
441
  recipients={[]}
335
442
  onRecipientsChange={vi.fn()}
336
443
  showPicker
337
- contacts={contacts}
338
- addedEmails={new Set(["alex@example.com"])}
444
+ contacts={[]}
445
+ onSearch={firstOnSearch}
339
446
  />,
340
447
  )
341
448
 
342
449
  fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
450
+ expect(firstOnSearch).toHaveBeenCalledTimes(1)
451
+ expect(firstOnSearch).toHaveBeenCalledWith("")
343
452
 
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"))!
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
+ )
347
464
 
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")
465
+ // Query is unchanged (""), so swapping the callback identity must not
466
+ // trigger another search.
467
+ expect(secondOnSearch).not.toHaveBeenCalled()
352
468
  })
353
469
  })
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { createPortal } from "react-dom"
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 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).
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 loading indicator in the picker while async results are fetched. */
65
+ /** Shows a "Searching contacts..." indicator while async results load. */
68
66
  searchLoading?: boolean
69
67
  }
70
68
 
@@ -120,86 +118,31 @@ function RecipientChipPill({
120
118
  )
121
119
  }
122
120
 
123
- function ContactPickerPopover({
124
- triggerRef,
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
-
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
146
 
204
147
  const asyncMode = typeof onSearch === "function"
205
148
 
@@ -245,16 +188,11 @@ function ContactPickerPopover({
245
188
  }
246
189
  }
247
190
 
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
- >
191
+ return (
192
+ <>
254
193
  <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
255
194
  <Search className="size-4 text-muted-foreground shrink-0" />
256
195
  <input
257
- ref={searchRef}
258
196
  autoFocus
259
197
  value={query}
260
198
  onChange={(event) => setQuery(event.target.value)}
@@ -266,16 +204,25 @@ function ContactPickerPopover({
266
204
 
267
205
  <div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
268
206
  {searchLoading ? (
269
- <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
207
+ <div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
270
208
  Searching contacts...
271
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>
272
223
  ) : filtered.length === 0 ? (
273
224
  <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
274
- {asyncMode && normalizedQuery.length === 0 ? (
275
- <div>Type a name or email to search contacts.</div>
276
- ) : (
277
- <div>No contact matches &lsquo;{query}&rsquo;.</div>
278
- )}
225
+ <div>No contact matches &lsquo;{query}&rsquo;.</div>
279
226
  {queryIsEmail ? (
280
227
  <div className="mt-1">Press Enter to add {query}.</div>
281
228
  ) : null}
@@ -340,8 +287,7 @@ function ContactPickerPopover({
340
287
  <CornerDownLeft className="size-3 shrink-0" />
341
288
  <span>Type an address and press Enter to add someone not listed.</span>
342
289
  </div>
343
- </div>,
344
- document.body,
290
+ </>
345
291
  )
346
292
  }
347
293
 
@@ -363,7 +309,6 @@ export function EmailRecipientField({
363
309
  }: EmailRecipientFieldProps) {
364
310
  const [value, setValue] = React.useState("")
365
311
  const [pickerOpen, setPickerOpen] = React.useState(false)
366
- const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
367
312
 
368
313
  const hasUnconfirmed = recipients.some((r) => !r.confirmed)
369
314
  const state: "default" | "amber" =
@@ -464,16 +409,39 @@ export function EmailRecipientField({
464
409
  {showPicker || showCcBcc ? (
465
410
  <div className="flex gap-1.5 mt-2">
466
411
  {showPicker ? (
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>
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>
477
445
  ) : null}
478
446
  {showCcBcc ? (
479
447
  <button
@@ -487,22 +455,6 @@ export function EmailRecipientField({
487
455
  ) : null}
488
456
  </div>
489
457
  ) : null}
490
-
491
- {pickerOpen ? (
492
- <ContactPickerPopover
493
- triggerRef={contactsTriggerRef}
494
- contacts={contacts}
495
- addedEmails={added}
496
- onSelect={selectContact}
497
- onAddEmail={(email) => {
498
- addEmail(email)
499
- setPickerOpen(false)
500
- }}
501
- onClose={() => setPickerOpen(false)}
502
- onSearch={onSearch}
503
- searchLoading={searchLoading}
504
- />
505
- ) : null}
506
458
  </div>
507
459
  </div>
508
460
  )