@handled-ai/design-system 0.18.40 → 0.18.42

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